Tenant Model
Tenant Hierarchy
Section titled “Tenant Hierarchy”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)Tables
Section titled “Tables”organizations
Section titled “organizations”The legal entity (real estate developer). Each organization has one or more projects.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
name | text | Organization name |
slug | text UNIQUE | URL-safe identifier |
projects
Section titled “projects”A specific real estate development project. All tenant-scoped data belongs to a project.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
organization_id | uuid FK → organizations | |
name | text | Project display name |
slug | text UNIQUE | |
status | project_status | active or archived |
portals
Section titled “portals”Branding and configuration for a project’s buyer portal. Replaces the old portal_settings singleton.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
project_id | uuid FK → projects | |
project_name | text | Display name in portal |
primary_color, accent_color, etc. | text | Branding tokens |
is_public_registration_enabled | boolean | Allow open registration |
translation_enabled | boolean | DeepL-powered i18n |
portal_domains
Section titled “portal_domains”Hostnames that map to a portal. Used for Host-based tenant resolution.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
portal_id | uuid FK → portals | |
hostname | text UNIQUE | e.g., sonnenhof.at |
is_primary | boolean | Primary domain for the portal |
cf_custom_hostname_id | text | Cloudflare for SaaS hostname ID |
status | portal_domain_status | pending, active, failed |
Tenant Scoping
Section titled “Tenant Scoping”Every tenant-scoped table carries a project_id column. Queries are filtered using:
- Application-level —
withTenantFilter(ctx, table.projectId, ...)in everyWHEREclause - RLS safety net —
withTenantSession()wraps DB work in a transaction that sets Postgres GUCs evaluated by RLS policies
Tenant Resolution
Section titled “Tenant Resolution”Buyer Portal — Host-based
Section titled “Buyer Portal — Host-based”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 — Session-based
Section titled “CRM — Session-based”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.
Operator — Request-based
Section titled “Operator — Request-based”Operators specify which project to operate on via headers or URL path parameters. No persistent session — stateless per-request.
Tenant Context Type
Section titled “Tenant Context Type”type TenantContext = { projectId: string | null; organizationId?: string | null; portalId?: string | null; isOperator?: boolean;};