Skip to content

Tenant Model

The tenant model reflects real-estate reality: developers run multiple projects over time, each with its own buyer portal.

organizations (Acme Bauträger GmbH)
└── projects (Sonnenhof Wien 22)
└── portals (buyer-portal config + branding)
└── portal_domains (sonnenhof.at, www.sonnenhof.at)

The legal entity (real estate developer). Each organization has one or more projects.

ColumnTypeNotes
iduuid PK
nametextOrganization name
slugtext UNIQUEURL-safe identifier

A specific real estate development project. All tenant-scoped data belongs to a project.

ColumnTypeNotes
iduuid PK
organization_iduuid FK → organizations
nametextProject display name
slugtext UNIQUE
statusproject_statusactive or archived

Branding and configuration for a project’s buyer portal. Replaces the old portal_settings singleton.

ColumnTypeNotes
iduuid PK
project_iduuid FK → projects
project_nametextDisplay name in portal
primary_color, accent_color, etc.textBranding tokens
is_public_registration_enabledbooleanAllow open registration
translation_enabledbooleanDeepL-powered i18n

Hostnames that map to a portal. Used for Host-based tenant resolution.

ColumnTypeNotes
iduuid PK
portal_iduuid FK → portals
hostnametext UNIQUEe.g., sonnenhof.at
is_primarybooleanPrimary domain for the portal
cf_custom_hostname_idtextCloudflare for SaaS hostname ID
statusportal_domain_statuspending, active, failed

Every tenant-scoped table carries a project_id column. Queries are filtered using:

  1. Application-level — withTenantFilter(ctx, table.projectId, ...) in every WHERE clause
  2. RLS safety net — withTenantSession() wraps DB work in a transaction that sets Postgres GUCs evaluated by RLS policies

The Host header is resolved against portal_domains → portals → projects → organizations at the start of every request. Results are cached for 60 seconds in an in-memory LRU.

// src/middleware.ts (simplified)
const hostname = request.headers.get("host") || "";
const resolved = await resolveHost(hostname);
if (resolved) {
context.locals.tenant = resolved;
}

CRM users (agents, developers) have an active project stored in their Lucia session (admin_sessions in Postgres — persists across Worker redeploys). The project can be switched via POST /api/admin/session/active-project.

  • Developers see all projects in their organization (memberships).
  • Agents see only assigned projects (agent_project_assignments), which may belong to different organizations.

Operators specify which project to operate on via headers or URL path parameters. No persistent session — stateless per-request.

type TenantContext = {
projectId: string | null;
organizationId?: string | null;
portalId?: string | null;
isOperator?: boolean;
};