Skip to main content

Database Schema Architecture

This document details the database schema design for the Stayzr Hotel Management System, focusing on multi-tenant data isolation, performance optimization, and scalability.

🗄️ Database Overview

The platform uses PostgreSQL 15+ as the primary database with a single-database, multi-tenant approach that ensures data isolation while maintaining operational efficiency.

Key Design Principles

  • Multi-tenant isolation with organization-based row-level security
  • Normalized schema with efficient relationships
  • Audit trails for compliance and data integrity
  • Scalable indexing for performance optimization
  • Data encryption for sensitive information

🏗️ Schema Architecture

Multi-Tenant Foundation

-- Core tenant entity
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
tenant_id VARCHAR(100) UNIQUE NOT NULL,

-- Subscription details
subscription_tier subscription_tier_enum NOT NULL DEFAULT 'starter',
subscription_status subscription_status_enum NOT NULL DEFAULT 'trial',
subscription_start_date TIMESTAMP WITH TIME ZONE,
subscription_end_date TIMESTAMP WITH TIME ZONE,

-- Contact information
primary_email VARCHAR(255),
primary_phone VARCHAR(50),
website_url VARCHAR(255),

-- Address
street_address TEXT,
city VARCHAR(100),
state_province VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100),
timezone VARCHAR(50) DEFAULT 'UTC',

-- Business details
business_registration_number VARCHAR(100),
tax_identification_number VARCHAR(100),
industry VARCHAR(100) DEFAULT 'hospitality',

-- Platform settings
default_language VARCHAR(10) DEFAULT 'en',
default_currency VARCHAR(3) DEFAULT 'USD',
date_format VARCHAR(20) DEFAULT 'YYYY-MM-DD',

-- Status and metadata
is_active BOOLEAN NOT NULL DEFAULT true,
onboarding_completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);

-- Subscription tier enumeration
CREATE TYPE subscription_tier_enum AS ENUM (
'starter',
'professional',
'enterprise',
'custom'
);

-- Subscription status enumeration
CREATE TYPE subscription_status_enum AS ENUM (
'trial',
'active',
'past_due',
'cancelled',
'suspended'
);

👥 User Management Schema

Core User Entity

CREATE TABLE users (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

-- Authentication
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
email_verification_token VARCHAR(255),

-- Profile information
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
display_name VARCHAR(200),
avatar_url VARCHAR(500),
phone_number VARCHAR(50),

-- System access
is_active BOOLEAN NOT NULL DEFAULT true,
is_super_admin BOOLEAN NOT NULL DEFAULT false,
current_organization_id INTEGER REFERENCES organizations(id),
last_login_at TIMESTAMP WITH TIME ZONE,
last_active_at TIMESTAMP WITH TIME ZONE,

-- Security
password_reset_token VARCHAR(255),
password_reset_expires_at TIMESTAMP WITH TIME ZONE,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP WITH TIME ZONE,

-- Multi-factor authentication
mfa_enabled BOOLEAN NOT NULL DEFAULT false,
mfa_secret VARCHAR(255),
mfa_backup_codes TEXT[],

-- Preferences
language VARCHAR(10) DEFAULT 'en',
timezone VARCHAR(50),
notification_preferences JSONB DEFAULT '{}',

-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,

-- Constraints
UNIQUE(organization_id, email),
CONSTRAINT email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);

-- User roles and permissions
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

name VARCHAR(100) NOT NULL,
description TEXT,
permissions TEXT[] NOT NULL DEFAULT '{}',
is_system_role BOOLEAN NOT NULL DEFAULT false,

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, name)
);

CREATE TABLE user_roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
assigned_by INTEGER REFERENCES users(id),

PRIMARY KEY (user_id, role_id)
);

🏨 Hotel Core Schema

Property and Room Management

-- Hotel properties
CREATE TABLE properties (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

-- Basic information
name VARCHAR(255) NOT NULL,
description TEXT,
property_type property_type_enum NOT NULL DEFAULT 'hotel',
star_rating INTEGER CHECK (star_rating >= 1 AND star_rating <= 5),

-- Contact and location
email VARCHAR(255),
phone VARCHAR(50),
website VARCHAR(255),

-- Address
street_address TEXT NOT NULL,
city VARCHAR(100) NOT NULL,
state_province VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100) NOT NULL,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),

-- Operational details
check_in_time TIME DEFAULT '15:00:00',
check_out_time TIME DEFAULT '11:00:00',
timezone VARCHAR(50) NOT NULL,

-- Amenities and features
amenities TEXT[],
policies JSONB DEFAULT '{}',

-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, name)
);

CREATE TYPE property_type_enum AS ENUM (
'hotel',
'resort',
'motel',
'bed_and_breakfast',
'vacation_rental',
'boutique_hotel',
'business_hotel',
'extended_stay'
);

-- Room types
CREATE TABLE room_types (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

-- Basic information
name VARCHAR(255) NOT NULL,
description TEXT,
short_description VARCHAR(500),

-- Capacity and configuration
max_occupancy INTEGER NOT NULL CHECK (max_occupancy > 0),
max_adults INTEGER NOT NULL CHECK (max_adults > 0),
max_children INTEGER DEFAULT 0,
bed_configuration VARCHAR(255),
room_size_sqft INTEGER,
room_size_sqm INTEGER,

-- Amenities and features
amenities TEXT[],
features JSONB DEFAULT '{}',

-- Pricing
base_rate DECIMAL(10, 2) NOT NULL CHECK (base_rate >= 0),
currency VARCHAR(3) DEFAULT 'USD',

-- Media
images JSONB DEFAULT '[]',
virtual_tour_url VARCHAR(500),

-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, property_id, name)
);

-- Individual rooms
CREATE TABLE rooms (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
room_type_id INTEGER NOT NULL REFERENCES room_types(id) ON DELETE CASCADE,

-- Room identification
room_number VARCHAR(50) NOT NULL,
floor_number INTEGER,
building VARCHAR(100),

-- Status and condition
status room_status_enum NOT NULL DEFAULT 'available',
condition room_condition_enum NOT NULL DEFAULT 'clean',
last_cleaned_at TIMESTAMP WITH TIME ZONE,

-- Special features
is_accessible BOOLEAN NOT NULL DEFAULT false,
is_smoking_allowed BOOLEAN NOT NULL DEFAULT false,
special_features TEXT[],
notes TEXT,

-- Maintenance
last_maintenance_date DATE,
next_maintenance_date DATE,
maintenance_notes TEXT,

-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, property_id, room_number)
);

CREATE TYPE room_status_enum AS ENUM (
'available',
'occupied',
'reserved',
'out_of_order',
'maintenance',
'cleaning'
);

CREATE TYPE room_condition_enum AS ENUM (
'clean',
'dirty',
'needs_inspection',
'maintenance_required'
);

👤 Guest Management Schema

Guest Profiles and Preferences

-- Guest profiles
CREATE TABLE guests (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

-- Personal information
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
middle_name VARCHAR(100),
preferred_name VARCHAR(100),
title VARCHAR(50),

-- Contact information
email VARCHAR(255),
phone_primary VARCHAR(50),
phone_secondary VARCHAR(50),

-- Demographics
date_of_birth DATE,
gender gender_enum,
nationality VARCHAR(100),
language_preference VARCHAR(10) DEFAULT 'en',

-- Address
street_address TEXT,
city VARCHAR(100),
state_province VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100),

-- Identity documents
passport_number VARCHAR(50),
passport_country VARCHAR(100),
passport_expiry_date DATE,
national_id VARCHAR(100),
driver_license VARCHAR(100),

-- Guest preferences
room_preferences JSONB DEFAULT '{}',
dining_preferences JSONB DEFAULT '{}',
accessibility_needs TEXT[],
special_requests TEXT,
allergies TEXT[],

-- Communication preferences
preferred_contact_method contact_method_enum DEFAULT 'email',
marketing_opt_in BOOLEAN NOT NULL DEFAULT false,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,

-- Guest history and loyalty
total_stays INTEGER DEFAULT 0,
total_nights INTEGER DEFAULT 0,
total_revenue DECIMAL(12, 2) DEFAULT 0,
first_stay_date DATE,
last_stay_date DATE,
loyalty_tier loyalty_tier_enum DEFAULT 'standard',
loyalty_points INTEGER DEFAULT 0,

-- Privacy and compliance
data_processing_consent BOOLEAN NOT NULL DEFAULT false,
data_processing_consent_date TIMESTAMP WITH TIME ZONE,
gdpr_consent BOOLEAN NOT NULL DEFAULT false,

-- Status and metadata
is_active BOOLEAN NOT NULL DEFAULT true,
is_vip BOOLEAN NOT NULL DEFAULT false,
blacklisted BOOLEAN NOT NULL DEFAULT false,
blacklist_reason TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,

-- Constraints
UNIQUE(organization_id, email),
CONSTRAINT email_format CHECK (email IS NULL OR email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);

CREATE TYPE gender_enum AS ENUM ('male', 'female', 'non_binary', 'prefer_not_to_say');
CREATE TYPE contact_method_enum AS ENUM ('email', 'phone', 'sms', 'app_notification');
CREATE TYPE loyalty_tier_enum AS ENUM ('standard', 'silver', 'gold', 'platinum', 'diamond');

-- Guest companions for group bookings
CREATE TABLE guest_companions (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
primary_guest_id INTEGER NOT NULL REFERENCES guests(id) ON DELETE CASCADE,

-- Companion information
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
relationship VARCHAR(100),
date_of_birth DATE,

-- Special needs
accessibility_needs TEXT[],
allergies TEXT[],

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

📅 Reservation Management Schema

Bookings and Availability

-- Reservations
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
guest_id INTEGER NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
room_type_id INTEGER NOT NULL REFERENCES room_types(id) ON DELETE CASCADE,
room_id INTEGER REFERENCES rooms(id),

-- Reservation identification
confirmation_number VARCHAR(50) UNIQUE NOT NULL,
external_booking_id VARCHAR(100),
booking_source booking_source_enum NOT NULL DEFAULT 'direct',

-- Dates and duration
check_in_date DATE NOT NULL,
check_out_date DATE NOT NULL,
nights INTEGER GENERATED ALWAYS AS (check_out_date - check_in_date) STORED,

-- Guest details
number_of_adults INTEGER NOT NULL DEFAULT 1 CHECK (number_of_adults > 0),
number_of_children INTEGER NOT NULL DEFAULT 0 CHECK (number_of_children >= 0),
number_of_infants INTEGER NOT NULL DEFAULT 0 CHECK (number_of_infants >= 0),

-- Pricing
room_rate DECIMAL(10, 2) NOT NULL CHECK (room_rate >= 0),
total_amount DECIMAL(12, 2) NOT NULL CHECK (total_amount >= 0),
taxes_amount DECIMAL(10, 2) DEFAULT 0,
fees_amount DECIMAL(10, 2) DEFAULT 0,
currency VARCHAR(3) DEFAULT 'USD',

-- Payment status
payment_status payment_status_enum NOT NULL DEFAULT 'pending',
deposit_amount DECIMAL(10, 2) DEFAULT 0,
deposit_paid BOOLEAN NOT NULL DEFAULT false,
balance_due DECIMAL(10, 2) DEFAULT 0,

-- Reservation status
status reservation_status_enum NOT NULL DEFAULT 'confirmed',
cancellation_reason TEXT,
cancelled_at TIMESTAMP WITH TIME ZONE,
cancelled_by INTEGER REFERENCES users(id),

-- Special requests and notes
special_requests TEXT,
internal_notes TEXT,
guest_notes TEXT,

-- Check-in/out details
checked_in_at TIMESTAMP WITH TIME ZONE,
checked_out_at TIMESTAMP WITH TIME ZONE,
early_checkin_requested BOOLEAN DEFAULT false,
late_checkout_requested BOOLEAN DEFAULT false,

-- Package and add-ons
package_inclusions JSONB DEFAULT '[]',
add_ons JSONB DEFAULT '[]',

-- Marketing and source
promotional_code VARCHAR(100),
discount_amount DECIMAL(10, 2) DEFAULT 0,
marketing_source VARCHAR(255),
referral_code VARCHAR(100),

-- Policies
cancellation_policy_id INTEGER,
no_show_policy_id INTEGER,

-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

-- Constraints
CHECK (check_out_date > check_in_date),
CHECK (total_amount >= 0),
CHECK (number_of_adults + number_of_children <= 20)
);

CREATE TYPE booking_source_enum AS ENUM (
'direct',
'booking_com',
'expedia',
'airbnb',
'agoda',
'hotels_com',
'tripadvisor',
'phone',
'walk_in',
'corporate',
'other'
);

CREATE TYPE payment_status_enum AS ENUM (
'pending',
'partial',
'paid',
'refunded',
'failed',
'disputed'
);

CREATE TYPE reservation_status_enum AS ENUM (
'confirmed',
'checked_in',
'checked_out',
'cancelled',
'no_show',
'modified'
);

-- Room assignments (for tracking room changes)
CREATE TABLE room_assignments (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,

assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
assigned_by INTEGER REFERENCES users(id),
reason VARCHAR(255),

-- Date range for the assignment
from_date DATE NOT NULL,
to_date DATE NOT NULL,

is_active BOOLEAN NOT NULL DEFAULT true
);

🛎️ Service Management Schema

Staff and Departments

-- Departments
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

name VARCHAR(100) NOT NULL,
description TEXT,
head_of_department INTEGER REFERENCES users(id),

-- Operating hours
operating_hours JSONB DEFAULT '{}',
contact_email VARCHAR(255),
contact_phone VARCHAR(50),

-- Budget and costs
budget_allocation DECIMAL(12, 2),
cost_center_code VARCHAR(50),

is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, name)
);

-- Staff management
CREATE TABLE staff (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
department_id INTEGER REFERENCES departments(id),

-- Employment details
employee_id VARCHAR(50) NOT NULL,
job_title VARCHAR(255) NOT NULL,
employment_type employment_type_enum NOT NULL DEFAULT 'full_time',
hire_date DATE NOT NULL,
termination_date DATE,

-- Compensation
hourly_rate DECIMAL(8, 2),
salary_annual DECIMAL(12, 2),
currency VARCHAR(3) DEFAULT 'USD',

-- Contact and emergency
emergency_contact_name VARCHAR(255),
emergency_contact_phone VARCHAR(50),
emergency_contact_relationship VARCHAR(100),

-- Skills and certifications
skills TEXT[],
certifications JSONB DEFAULT '[]',
languages TEXT[],

-- Performance and status
performance_rating DECIMAL(3, 2) CHECK (performance_rating >= 0 AND performance_rating <= 5),
last_review_date DATE,
next_review_date DATE,

is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, employee_id),
UNIQUE(organization_id, user_id)
);

CREATE TYPE employment_type_enum AS ENUM (
'full_time',
'part_time',
'contract',
'temporary',
'intern',
'consultant'
);

-- Service requests and tasks
CREATE TABLE service_requests (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
guest_id INTEGER REFERENCES guests(id),
reservation_id INTEGER REFERENCES reservations(id),
room_id INTEGER REFERENCES rooms(id),

-- Request details
type service_request_type_enum NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
priority priority_level_enum NOT NULL DEFAULT 'medium',

-- Assignment
assigned_to INTEGER REFERENCES staff(id),
assigned_department INTEGER REFERENCES departments(id),
assigned_at TIMESTAMP WITH TIME ZONE,

-- Status and tracking
status service_request_status_enum NOT NULL DEFAULT 'pending',
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
estimated_completion TIMESTAMP WITH TIME ZONE,

-- Guest communication
guest_notification_sent BOOLEAN DEFAULT false,
guest_satisfaction_rating INTEGER CHECK (guest_satisfaction_rating >= 1 AND guest_satisfaction_rating <= 5),
guest_feedback TEXT,

-- Internal notes
internal_notes TEXT,
completion_notes TEXT,

-- Attachments and media
attachments JSONB DEFAULT '[]',

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE service_request_type_enum AS ENUM (
'housekeeping',
'maintenance',
'concierge',
'room_service',
'transportation',
'laundry',
'spa',
'it_support',
'other'
);

CREATE TYPE priority_level_enum AS ENUM ('low', 'medium', 'high', 'urgent');

CREATE TYPE service_request_status_enum AS ENUM (
'pending',
'assigned',
'in_progress',
'completed',
'cancelled',
'on_hold'
);

💰 Financial Schema

Billing and Payments

-- Invoices and billing
CREATE TABLE invoices (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
guest_id INTEGER NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
reservation_id INTEGER REFERENCES reservations(id),

-- Invoice identification
invoice_number VARCHAR(100) UNIQUE NOT NULL,
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
due_date DATE NOT NULL,

-- Amounts
subtotal DECIMAL(12, 2) NOT NULL CHECK (subtotal >= 0),
tax_amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
total_amount DECIMAL(12, 2) NOT NULL CHECK (total_amount >= 0),
currency VARCHAR(3) DEFAULT 'USD',

-- Payment status
status invoice_status_enum NOT NULL DEFAULT 'pending',
paid_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
balance_due DECIMAL(12, 2) GENERATED ALWAYS AS (total_amount - paid_amount) STORED,

-- Billing details
billing_address JSONB,
payment_terms INTEGER DEFAULT 30, -- days

-- Notes and references
notes TEXT,
external_reference VARCHAR(255),

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE invoice_status_enum AS ENUM (
'draft',
'pending',
'sent',
'paid',
'overdue',
'cancelled',
'refunded'
);

-- Invoice line items
CREATE TABLE invoice_line_items (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,

-- Item details
description VARCHAR(500) NOT NULL,
item_type item_type_enum NOT NULL DEFAULT 'room',
quantity DECIMAL(8, 2) NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price DECIMAL(10, 2) NOT NULL CHECK (unit_price >= 0),
total_price DECIMAL(12, 2) GENERATED ALWAYS AS (quantity * unit_price) STORED,

-- Dates
service_date DATE,
service_period_start DATE,
service_period_end DATE,

-- Tax information
tax_rate DECIMAL(5, 4) DEFAULT 0,
tax_amount DECIMAL(10, 2) DEFAULT 0,

-- References
room_id INTEGER REFERENCES rooms(id),
service_request_id INTEGER REFERENCES service_requests(id),

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE item_type_enum AS ENUM (
'room',
'tax',
'fee',
'service',
'food_beverage',
'spa',
'transportation',
'other'
);

-- Payments
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
invoice_id INTEGER REFERENCES invoices(id),
guest_id INTEGER NOT NULL REFERENCES guests(id),

-- Payment identification
payment_reference VARCHAR(100) UNIQUE NOT NULL,
external_payment_id VARCHAR(255),

-- Payment details
amount DECIMAL(12, 2) NOT NULL CHECK (amount > 0),
currency VARCHAR(3) DEFAULT 'USD',
payment_method payment_method_enum NOT NULL,

-- Credit card details (encrypted)
card_last_four VARCHAR(4),
card_brand VARCHAR(50),
card_expiry_month INTEGER,
card_expiry_year INTEGER,

-- Payment status
status payment_status_transaction_enum NOT NULL DEFAULT 'pending',
processed_at TIMESTAMP WITH TIME ZONE,
failure_reason TEXT,

-- Gateway information
payment_gateway VARCHAR(100),
gateway_transaction_id VARCHAR(255),
gateway_fee DECIMAL(8, 2) DEFAULT 0,

-- Refund information
refunded_amount DECIMAL(12, 2) DEFAULT 0,
refund_reason TEXT,
refunded_at TIMESTAMP WITH TIME ZONE,

-- Notes
notes TEXT,

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE payment_method_enum AS ENUM (
'credit_card',
'debit_card',
'bank_transfer',
'cash',
'check',
'digital_wallet',
'cryptocurrency',
'loyalty_points'
);

CREATE TYPE payment_status_transaction_enum AS ENUM (
'pending',
'processing',
'completed',
'failed',
'cancelled',
'refunded',
'disputed'
);

🔒 Security and Audit Schema

Data Protection and Compliance

-- Audit log for all data changes
CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
organization_id INTEGER REFERENCES organizations(id),

-- Action details
table_name VARCHAR(100) NOT NULL,
record_id INTEGER NOT NULL,
action audit_action_enum NOT NULL,

-- User context
user_id INTEGER REFERENCES users(id),
user_email VARCHAR(255),
user_ip_address INET,
user_agent TEXT,

-- Data changes
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],

-- Metadata
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
session_id VARCHAR(255),
request_id VARCHAR(255)
);

CREATE TYPE audit_action_enum AS ENUM ('INSERT', 'UPDATE', 'DELETE', 'SELECT');

-- Security events log
CREATE TABLE security_logs (
id SERIAL PRIMARY KEY,
organization_id INTEGER REFERENCES organizations(id),

-- Event details
event_type security_event_type_enum NOT NULL,
severity security_severity_enum NOT NULL DEFAULT 'info',
description TEXT NOT NULL,

-- User context
user_id INTEGER REFERENCES users(id),
user_email VARCHAR(255),
ip_address INET,
user_agent TEXT,

-- Additional context
metadata JSONB DEFAULT '{}',

-- Resolution
resolved BOOLEAN DEFAULT false,
resolved_at TIMESTAMP WITH TIME ZONE,
resolved_by INTEGER REFERENCES users(id),
resolution_notes TEXT,

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TYPE security_event_type_enum AS ENUM (
'login_success',
'login_failure',
'logout',
'password_change',
'password_reset',
'account_locked',
'permission_denied',
'suspicious_activity',
'data_export',
'admin_action'
);

CREATE TYPE security_severity_enum AS ENUM ('info', 'warning', 'error', 'critical');

-- Data retention policies
CREATE TABLE data_retention_policies (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

table_name VARCHAR(100) NOT NULL,
retention_period_days INTEGER NOT NULL CHECK (retention_period_days > 0),
purge_condition TEXT, -- SQL condition for selective purging

-- Policy details
policy_type data_retention_type_enum NOT NULL DEFAULT 'time_based',
is_active BOOLEAN NOT NULL DEFAULT true,

-- Compliance
legal_basis TEXT,
compliance_framework VARCHAR(100), -- GDPR, CCPA, etc.

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, table_name)
);

CREATE TYPE data_retention_type_enum AS ENUM ('time_based', 'event_based', 'manual');

📈 Analytics Schema

Reporting and Business Intelligence

-- Feature usage tracking
CREATE TABLE feature_usage (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

feature_code VARCHAR(100) NOT NULL,
current_usage INTEGER NOT NULL DEFAULT 0,
usage_limit INTEGER,

-- Tracking periods
tracking_period tracking_period_enum NOT NULL DEFAULT 'monthly',
period_start DATE NOT NULL,
period_end DATE NOT NULL,

-- Metadata
last_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

UNIQUE(organization_id, feature_code, period_start)
);

CREATE TYPE tracking_period_enum AS ENUM ('daily', 'weekly', 'monthly', 'yearly');

-- Performance metrics
CREATE TABLE performance_metrics (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
property_id INTEGER REFERENCES properties(id),

-- Metric identification
metric_name VARCHAR(100) NOT NULL,
metric_value DECIMAL(15, 4) NOT NULL,
metric_unit VARCHAR(50),

-- Time dimensions
measurement_date DATE NOT NULL,
measurement_hour INTEGER CHECK (measurement_hour >= 0 AND measurement_hour <= 23),

-- Categorization
category VARCHAR(100),
subcategory VARCHAR(100),

-- Additional context
metadata JSONB DEFAULT '{}',

recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create indexes for common queries
CREATE INDEX idx_performance_metrics_org_date ON performance_metrics(organization_id, measurement_date);
CREATE INDEX idx_performance_metrics_metric_name ON performance_metrics(metric_name);

🔍 Indexing Strategy

Performance Optimization

-- Multi-tenant isolation indexes (most critical)
CREATE INDEX idx_organizations_tenant_id ON organizations(tenant_id);
CREATE INDEX idx_users_org_email ON users(organization_id, email);
CREATE INDEX idx_guests_org_email ON guests(organization_id, email);
CREATE INDEX idx_reservations_org_dates ON reservations(organization_id, check_in_date, check_out_date);
CREATE INDEX idx_rooms_org_property ON rooms(organization_id, property_id);

-- Lookup and search indexes
CREATE INDEX idx_reservations_confirmation ON reservations(confirmation_number);
CREATE INDEX idx_guests_phone ON guests(phone_primary);
CREATE INDEX idx_staff_employee_id ON staff(organization_id, employee_id);

-- Date-based queries
CREATE INDEX idx_reservations_checkin ON reservations(check_in_date);
CREATE INDEX idx_reservations_checkout ON reservations(check_out_date);
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp);

-- Full-text search indexes
CREATE INDEX idx_guests_name_search ON guests USING gin(to_tsvector('english', first_name || ' ' || last_name));
CREATE INDEX idx_service_requests_text_search ON service_requests USING gin(to_tsvector('english', title || ' ' || description));

-- Composite indexes for complex queries
CREATE INDEX idx_reservations_status_dates ON reservations(organization_id, status, check_in_date, check_out_date);
CREATE INDEX idx_payments_status_date ON payments(organization_id, status, created_at);
CREATE INDEX idx_invoices_guest_status ON invoices(organization_id, guest_id, status);

-- Partial indexes for active records
CREATE INDEX idx_active_users ON users(organization_id, id) WHERE is_active = true;
CREATE INDEX idx_active_staff ON staff(organization_id, id) WHERE is_active = true;
CREATE INDEX idx_active_rooms ON rooms(organization_id, property_id) WHERE is_active = true;

🛡️ Row Level Security

Multi-Tenant Data Isolation

-- Enable RLS on all tenant tables
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE properties ENABLE ROW LEVEL SECURITY;
ALTER TABLE rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE room_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE guests ENABLE ROW LEVEL SECURITY;
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;
ALTER TABLE service_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;

-- Create RLS policies for tenant isolation
CREATE POLICY tenant_isolation_organizations ON organizations
FOR ALL TO authenticated_users
USING (id = current_setting('app.current_organization_id')::int);

CREATE POLICY tenant_isolation_users ON users
FOR ALL TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::int);

CREATE POLICY tenant_isolation_guests ON guests
FOR ALL TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::int);

CREATE POLICY tenant_isolation_reservations ON reservations
FOR ALL TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::int);

-- Super admin bypass policy (highest priority)
CREATE POLICY super_admin_bypass ON users
FOR ALL TO super_admin_role
USING (true);

-- Additional policies can be created for more granular access control
CREATE POLICY guest_data_access ON guests
FOR SELECT TO guest_service_role
USING (
organization_id = current_setting('app.current_organization_id')::int
AND (
-- Staff can see all guests in their organization
current_user_is_staff()
OR
-- Guests can only see their own data
id = current_guest_id()
)
);

🔧 Database Functions and Triggers

Automated Data Management

-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';

-- Apply to all tables with updated_at column
CREATE TRIGGER update_organizations_updated_at BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_guests_updated_at BEFORE UPDATE ON guests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_reservations_updated_at BEFORE UPDATE ON reservations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

-- Function to generate confirmation numbers
CREATE OR REPLACE FUNCTION generate_confirmation_number()
RETURNS TEXT AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
result TEXT := '';
i INTEGER;
BEGIN
FOR i IN 1..8 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;

-- Trigger to auto-generate confirmation numbers
CREATE OR REPLACE FUNCTION set_confirmation_number()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.confirmation_number IS NULL THEN
NEW.confirmation_number := generate_confirmation_number();
-- Ensure uniqueness
WHILE EXISTS (SELECT 1 FROM reservations WHERE confirmation_number = NEW.confirmation_number) LOOP
NEW.confirmation_number := generate_confirmation_number();
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER set_reservation_confirmation_number
BEFORE INSERT ON reservations
FOR EACH ROW EXECUTE FUNCTION set_confirmation_number();

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

-- Apply audit triggers to sensitive tables
CREATE TRIGGER audit_users AFTER INSERT OR UPDATE OR DELETE ON users FOR EACH ROW EXECUTE FUNCTION audit_trigger();
CREATE TRIGGER audit_guests AFTER INSERT OR UPDATE OR DELETE ON guests FOR EACH ROW EXECUTE FUNCTION audit_trigger();
CREATE TRIGGER audit_reservations AFTER INSERT OR UPDATE OR DELETE ON reservations FOR EACH ROW EXECUTE FUNCTION audit_trigger();
CREATE TRIGGER audit_payments AFTER INSERT OR UPDATE OR DELETE ON payments FOR EACH ROW EXECUTE FUNCTION audit_trigger();

📊 Database Views

Reporting and Analytics Views

-- Occupancy analytics view
CREATE VIEW v_occupancy_analytics AS
SELECT
r.organization_id,
r.property_id,
p.name as property_name,
r.check_in_date,
r.check_out_date,
COUNT(DISTINCT r.room_id) as occupied_rooms,
COUNT(DISTINCT rooms.id) as total_rooms,
ROUND(COUNT(DISTINCT r.room_id)::numeric / COUNT(DISTINCT rooms.id) * 100, 2) as occupancy_rate,
SUM(r.total_amount) as total_revenue,
AVG(r.total_amount / r.nights) as average_daily_rate
FROM reservations r
JOIN properties p ON r.property_id = p.id
JOIN rooms ON rooms.property_id = p.id
WHERE r.status IN ('confirmed', 'checked_in', 'checked_out')
GROUP BY r.organization_id, r.property_id, p.name, r.check_in_date, r.check_out_date;

-- Guest analytics view
CREATE VIEW v_guest_analytics AS
SELECT
g.organization_id,
g.id as guest_id,
g.first_name || ' ' || g.last_name as guest_name,
g.email,
g.loyalty_tier,
COUNT(r.id) as total_reservations,
SUM(r.total_amount) as total_revenue,
AVG(r.total_amount) as average_reservation_value,
MAX(r.check_out_date) as last_stay_date,
MIN(r.check_in_date) as first_stay_date
FROM guests g
LEFT JOIN reservations r ON g.id = r.guest_id AND r.status = 'checked_out'
GROUP BY g.organization_id, g.id, g.first_name, g.last_name, g.email, g.loyalty_tier;

-- Staff performance view
CREATE VIEW v_staff_performance AS
SELECT
s.organization_id,
s.id as staff_id,
u.first_name || ' ' || u.last_name as staff_name,
d.name as department_name,
s.job_title,
COUNT(sr.id) as total_tasks,
COUNT(CASE WHEN sr.status = 'completed' THEN 1 END) as completed_tasks,
ROUND(COUNT(CASE WHEN sr.status = 'completed' THEN 1 END)::numeric / NULLIF(COUNT(sr.id), 0) * 100, 2) as completion_rate,
AVG(EXTRACT(EPOCH FROM (sr.completed_at - sr.started_at))/3600) as avg_completion_hours,
AVG(sr.guest_satisfaction_rating) as avg_guest_rating
FROM staff s
JOIN users u ON s.user_id = u.id
LEFT JOIN departments d ON s.department_id = d.id
LEFT JOIN service_requests sr ON s.id = sr.assigned_to
GROUP BY s.organization_id, s.id, u.first_name, u.last_name, d.name, s.job_title;

🔄 Data Maintenance

Cleanup and Optimization

-- Function to archive old data
CREATE OR REPLACE FUNCTION archive_old_data(retention_days INTEGER DEFAULT 2555) -- 7 years default
RETURNS INTEGER AS $$
DECLARE
archived_count INTEGER := 0;
cutoff_date DATE := CURRENT_DATE - retention_days;
BEGIN
-- Archive old audit logs
WITH archived AS (
DELETE FROM audit_logs
WHERE timestamp < cutoff_date
RETURNING id
)
SELECT COUNT(*) INTO archived_count FROM archived;

-- Archive old security logs
DELETE FROM security_logs
WHERE created_at < (CURRENT_DATE - INTERVAL '2 years');

-- Archive completed service requests older than 1 year
DELETE FROM service_requests
WHERE status = 'completed'
AND completed_at < (CURRENT_DATE - INTERVAL '1 year');

RETURN archived_count;
END;
$$ LANGUAGE plpgsql;

-- Function to update performance metrics
CREATE OR REPLACE FUNCTION update_performance_metrics()
RETURNS VOID AS $$
BEGIN
-- Calculate daily occupancy rates
INSERT INTO performance_metrics (organization_id, property_id, metric_name, metric_value, measurement_date, category)
SELECT
p.organization_id,
p.id,
'occupancy_rate',
COALESCE(
(COUNT(DISTINCT r.room_id)::decimal / COUNT(DISTINCT rooms.id)) * 100,
0
),
CURRENT_DATE - 1,
'operational'
FROM properties p
LEFT JOIN rooms ON rooms.property_id = p.id AND rooms.is_active = true
LEFT JOIN reservations r ON r.property_id = p.id
AND r.check_in_date <= CURRENT_DATE - 1
AND r.check_out_date > CURRENT_DATE - 1
AND r.status IN ('confirmed', 'checked_in', 'checked_out')
GROUP BY p.organization_id, p.id
ON CONFLICT (organization_id, metric_name, measurement_date) DO UPDATE
SET metric_value = EXCLUDED.metric_value;

-- Calculate revenue metrics
INSERT INTO performance_metrics (organization_id, property_id, metric_name, metric_value, measurement_date, category)
SELECT
organization_id,
property_id,
'daily_revenue',
COALESCE(SUM(total_amount), 0),
CURRENT_DATE - 1,
'financial'
FROM reservations
WHERE check_in_date = CURRENT_DATE - 1
AND status IN ('confirmed', 'checked_in', 'checked_out')
GROUP BY organization_id, property_id
ON CONFLICT (organization_id, metric_name, measurement_date) DO UPDATE
SET metric_value = EXCLUDED.metric_value;
END;
$$ LANGUAGE plpgsql;

-- Schedule daily metrics update
SELECT cron.schedule('update-metrics', '0 1 * * *', 'SELECT update_performance_metrics();');