\nhttps://myagency.propertywebbuilder.com\n └─ subdomain identifies the tenant\n\n\nOne database, multiple websites?\nYes! Single PostgreSQL database with website_id column to separate data:\nruby\nMyModel.where(website_id: 42) # Only data for website 42\nMyModel.all # ❌ WRONG - returns data from ALL websites!\n\n\n---\n\n## Key Concepts (Must Know)\n\n### 1. Current Website Context\n\nEvery request sets the current website (automatic via SubdomainTenant concern):\n\nruby\n# In controllers and views\ncurrent_website # Available everywhere\nPwb::Current.website # Direct access\nPwb::Current.website.id # Website ID (42)\nPwb::Current.website.subdomain # \"myagency\"\n\n\n### 2. Two Environments (Read Carefully!)\n\nPwbTenant:: Models (Auto-scoped - PLANNED but not adopted)\nruby\n# These WOULD be automatically scoped (if we used them)\nPwbTenant::Page.all # Only returns pages for current_website\n\n\nPwb:: Models (Manual scoping - WHAT WE ACTUALLY USE)\nruby\n# These require MANUAL website_id filtering\nPwb::Page.all # ❌ Returns ALL pages from ALL websites\nPwb::Page.where(website_id: current_website.id) # ✅ Correct\n\n\n### 3. The Golden Rule\n\nALWAYS filter by website_id:\n\nruby\n# ✅ GOOD - Scoped queries\n@pages = Pwb::Page.where(website_id: current_website.id)\n@pages = current_website.pages # Via association\n\n# ❌ BAD - Unscoped queries (data leak!)\n@pages = Pwb::Page.all\n@pages = Pwb::Page.find(params[:id])\n\n\n---\n\n## Common Tasks\n\n### Task 1: List Records for Current Website\n\nruby\n# Option A: Via association (recommended)\n@pages = current_website.pages\n\n# Option B: Manual WHERE clause\n@pages = Pwb::Page.where(website_id: current_website.id)\n\n# With filtering\n@visible_pages = current_website.pages.where(visible: true).order(created_at: :desc)\n\n\n### Task 2: Find One Record Safely\n\nruby\n# ❌ WRONG - Could be from any website\n@page = Pwb::Page.find(params[:id])\n\n# ✅ CORRECT - Scoped to current website\n@page = current_website.pages.find(params[:id])\n\n# Alternative\n@page = Pwb::Page.find_by!(id: params[:id], website_id: current_website.id)\n\n\n### Task 3: Create a New Record\n\nruby\n# Option A: Via association (recommended)\n@page = current_website.pages.build(title: 'Home')\n@page.save\n\n# Option B: Manual assignment\n@page = Pwb::Page.new(title: 'Home')\n@page.website = current_website\n@page.save\n\n# Option C: With create\nPwb::Page.create!(\n title: 'Home',\n website_id: current_website.id\n)\n\n\n### Task 4: Admin Viewing All Websites\n\nruby\n# In TenantAdminController ONLY (not SiteAdminController!)\n@websites = Pwb::Website.unscoped.all\n\n# Never use .unscoped() in public/site admin code!\n\n\n---\n\n## The Three Controller Types\n\n### 1. Public Controller (No Auth)\nruby\nclass Pwb::PropertiesController < Pwb::ApplicationController\n include SubdomainTenant # Automatic subdomain routing\n \n def index\n # current_website set automatically\n @properties = current_website.realty_assets\n end\nend\n\n# URL: myagency.propertywebbuilder.com/properties\n\n\n### 2. Site Admin Controller (Single Website Admin)\nruby\nclass SiteAdmin::PropertiesController < SiteAdminController\n include SubdomainTenant # Automatic subdomain routing\n before_action :require_admin! # Auth check\n \n def index\n # current_website set automatically\n @properties = current_website.realty_assets\n end\nend\n\n# URL: myagency.propertywebbuilder.com/admin/properties\n# Auth: Must be logged in + admin for myagency website\n\n\n### 3. Tenant Admin Controller (Cross-Website Admin)\nruby\nclass TenantAdmin::WebsitesController < TenantAdminController\n # NO SubdomainTenant - we want to see all websites!\n before_action :require_tenant_admin! # Email whitelist\n \n def index\n # Deliberately unscoped to see all websites\n @websites = Pwb::Website.unscoped.all\n end\nend\n\n# URL: admin.propertywebbuilder.com/websites\n# Auth: Email must be in TENANT_ADMIN_EMAILS env var\n# Special: Can access any website's data\n\n\n---\n\n## Domain Types\n\n### Platform Subdomain (Default)\n\nhttps://myagency.propertywebbuilder.com\n └─ Subdomain-based routing\n\n\nHow it works:\n1. Rails extracts subdomain: "myagency"\n2. Looks up Website where subdomain='myagency'\n3. Sets current_website\n\nConfiguration:\nbash\n# Environment variable\nPLATFORM_DOMAINS=propertywebbuilder.com,pwb.localhost,e2e.localhost\n\n\n### Custom Domain (White-label)\n\nhttps://www.myrealestate.com\n └─ Custom domain\n\n\nHow it works:\n1. Rails receives host: "www.myrealestate.com"\n2. Checks if it's NOT a platform domain\n3. Looks up Website where custom_domain='www.myrealestate.com'\n4. Sets current_website\n\nSetup:\nruby\n# In Website model\nwebsite.custom_domain = 'www.myrealestate.com'\nwebsite.custom_domain_verification_token = '...' # For DNS verification\nwebsite.custom_domain_verified = true\nwebsite.save\n\n\nDNS Setup:\n- Add CNAME record: www.myrealestate.com → propertywebbuilder.com\n- Or A record to platform IP\n\n---\n\n## Models You'll Use\n\n### Website-Scoped (Must filter by website_id)\nruby\nPwb::Page # Website pages\nPwb::Content # Content translations\nPwb::Message # Contact form messages\nPwb::Contact # Stored contacts\nPwb::RealtyAsset # Property listings\nPwb::SaleListing # Sale-specific data\nPwb::RentalListing # Rental-specific data\nPwb::PropPhoto # Property images\nPwb::Link # Navigation links\nPwb::FieldKey # Custom property fields\nPwb::PagePart # Page template sections\n\n\n### NOT Website-Scoped (Global, use as-is)\nruby\nPwb::Website # The tenant root\nPwb::User # Can belong to multiple websites\nPwb::Agency # Agency for a website\nPwb::Subscription # Subscription/billing\n\n\n---\n\n## Testing Tenant Isolation\n\n### Test That Data Doesn't Leak\nruby\nRSpec.describe 'Tenant Isolation' do\n let(:website1) { create(:website, subdomain: 'site1') }\n let(:website2) { create(:website, subdomain: 'site2') }\n \n it 'does not show site2 pages in site1' do\n page1 = create(:page, website: website1)\n page2 = create(:page, website: website2)\n \n # Set tenant context\n Pwb::Current.website = website1\n \n # Query should only return page1\n pages = Pwb::Page.where(website_id: website1.id)\n expect(pages).to include(page1)\n expect(pages).not_to include(page2)\n end\nend\n\n\n### Test Authorization Boundary\nruby\nit 'requires admin role for site' do\n user = create(:user)\n website = create(:website)\n \n # User is NOT admin\n Pwb::Current.website = website\n current_user = user\n \n # Should fail authorization\n expect(user.admin_for?(website)).to be false\n \n # Make them admin\n create(:user_membership, user: user, website: website, role: 'admin')\n expect(user.admin_for?(website)).to be true\nend\n\n\n---\n\n## Debugging Checklist\n\n### "Wrong data showing in admin"\n- [ ] Check that query includes website_id filter\n- [ ] Verify current_website is set (check SubdomainTenant concern)\n- [ ] Check if using association (should auto-filter)\n- [ ] Look for .unscoped() or .all without WHERE\n\n### "Admin auth not working"\n- [ ] Check that controller includes SubdomainTenant\n- [ ] Verify require_admin! is called before action\n- [ ] Check UserMembership exists with correct role\n- [ ] If bypassing: is BYPASS_ADMIN_AUTH=true set?\n\n### "Wrong website accessed via custom domain"\n- [ ] Check that Website.find_by_host() is called\n- [ ] Verify custom domain is in database\n- [ ] Check if custom_domain_verified is true (or dev mode)\n- [ ] Verify DNS is configured correctly\n\n### "Data not saved to correct website"\n- [ ] Check that website_id is set on create\n- [ ] Verify using association: website.pages.create(...)\n- [ ] Check before_action sets ActsAsTenant.current_tenant\n- [ ] Look for manual WHERE clauses overriding website\n\n---\n\n## Anti-Patterns to Avoid\n\nruby\n# ❌ DON'T DO THIS\n\n# 1. Querying all records\nPwb::Page.all\nPwb::Page.find(id)\nPwb::Message.pluck(:content)\n\n# 2. Unscoped in public/admin code\nPwb::Page.unscoped.all\n\n# 3. Forgetting to set website on create\nPwb::Page.create!(title: 'Home') # website_id is nil!\n\n# 4. Using .unscoped() without authorization checks\nclass Pwb::PropertiesController < Pwb::ApplicationController\n def index\n @properties = Pwb::RealtyAsset.unscoped.all # ❌ SECURITY ISSUE\n end\nend\n\n# 5. Assuming current_website exists\ncurrent_website.name # ❌ Errors if nil\n\n# 6. Manual string interpolation with website_id\nPwb::Page.where(\"website_id = #{current_website.id}\") # ❌ SQL injection!\n\n\nruby\n# ✅ DO THIS INSTEAD\n\n# 1. Always filter by website_id\nPwb::Page.where(website_id: current_website.id)\ncurrent_website.pages\n\n# 2. Use unscoped only in TenantAdmin with auth\nclass TenantAdmin::WebsitesController < TenantAdminController\n def index\n @websites = Pwb::Website.unscoped.all # ✅ Authorized\n end\nend\n\n# 3. Set website on create\nPwb::Page.create!(title: 'Home', website_id: current_website.id)\ncurrent_website.pages.create!(title: 'Home') # ✅ Via association\n\n# 4. Check existence\nreturn unless current_website\ncurrent_website&.name # Safe nil handling\n\n# 5. Use parameterized queries (Rails default)\nPwb::Page.where(website_id: current_website&.id) # ✅ Parameterized\n\n\n---\n\n## Creating a New Website\n\n### Via API/Admin\nruby\nwebsite = Pwb::Website.create!(\n subdomain: 'myagency',\n slug: 'my-agency',\n company_display_name: 'My Agency Inc',\n theme_name: 'default',\n shard_name: 'default',\n supported_locales: ['en-UK'],\n default_client_locale: 'en-UK',\n default_currency: 'EUR'\n)\n\n# Result: website is now accessible at https://myagency.propertywebbuilder.com\n\n\n### Using Seed Packs (Quick Setup)\nruby\n# Apply pre-configured bundle\npack = Pwb::SeedPack.find('netherlands_urban')\npack.apply!(website: website)\n\n# Now website has:\n# - Sample properties\n# - Pages (home, about, contact)\n# - Navigation links\n# - Field definitions\n# - Admin users\n# - Content translations\n\n\n---\n\n## Key Files to Know\n\n| File | Purpose |\n|------|----------|\n| /app/models/pwb/website.rb | Tenant model (root of all tenancy) |\n| /app/models/pwb/current.rb | Thread-local storage for current website |\n| /app/controllers/concerns/subdomain_tenant.rb | Automatic subdomain routing |\n| /app/models/concerns/pwb/website_domain_configurable.rb | Domain resolution logic |\n| /config/initializers/acts_as_tenant.rb | Tenant gem config |\n| /config/initializers/tenant_domains.rb | Platform domain config |\n| /app/controllers/site_admin_controller.rb | Single-tenant admin base |\n| /app/controllers/tenant_admin_controller.rb | Cross-tenant admin base |\n| /lib/pwb/seed_pack.rb | Seed pack system |\n| /db/seeds.rb | Main seeding entry point |\n\n---\n\n## Environment Variables\n\nbash\n# Domain routing\nPLATFORM_DOMAINS=propertywebbuilder.com,pwb.localhost,e2e.localhost\n\n# Admin access\nTENANT_ADMIN_EMAILS=admin@example.com,super@example.com\n\n# Bypass auth (dev only!)\nBYPASS_ADMIN_AUTH=true\nDEV_SUBSCRIPTION_PLAN=enterprise\n\n# Database sharding\nPWB_DATABASE_URL=postgresql://...\nPWB_TENANT_SHARD_1_DATABASE_URL=postgresql://...\n\n\n---\n\n## Need More Info?\n\nSee the full documentation in /docs/multi_tenancy/:\n\n- README.md - Navigation guide\n- MULTI_TENANCY_ARCHITECTURE.md - Deep dive (REQUIRED READING)\n- MULTI_TENANCY_QUICK_REFERENCE.md - Patterns reference\n- DEVELOPER_GUIDE.md - Best practices\n- routing_implementation.md - Technical details\n\n---\n\n## TL;DR\n\n1. Each website = unique subdomain (or custom domain)\n2. One database with website_id column on most tables\n3. ALWAYS filter by website_id in queries\n4. current_website available in all controllers\n5. Never use .all or .find without website_id filter\n6. Three controller types: Public (no auth), SiteAdmin (single website), TenantAdmin (cross-website)\n\nGolden Rule: If a query returns data, it should only return data for the current website.\n"}