Files
nick-doc/02 - Data Models/Tenant.md
Siavash Sameni e52ffce48a docs: sync vault with codebase state (2026-06-12)
- Update backend, frontend, scanner, deployment, amanat-assist service docs
- Update System Overview, Scanner Architecture, Telegram Mini App flow
- Update 10 - Services/README.md
- Add Tenant data model, Tenant API reference, Tenant Storefront Flow
- Add Multi-Shop Branch Project Scan (2026-06-10)
- Add tenant.md service doc
- Append activity log entry
- Reflects archived/search/stats route fix and new E2E test suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:42:18 +04:00

12 KiB

title, tags, aliases
title tags aliases
Tenant
data-model
postgres
drizzle
white-label
multi-tenant
TenantRecord
Merchant Tenant
White-Label Shop

Tenant

Last updated: 2026-06-10 — current feature/white-label-shops scan.

Six Drizzle/PostgreSQL tables that form the multi-tenant layer of the Amanat marketplace operating system. Introduced by PRD - Seller-Owned White-Label Shops and Bots.

[!note] Source All six tables live in a single file: backend/src/db/schema/tenant.ts Repositories: backend/src/db/repositories/drizzle/DrizzleTenant*.ts Services: backend/src/services/tenant/


Table overview

Table Purpose Isolation key
tenants Top-level tenant entity (one per merchant) id (PK)
tenant_domains Custom / managed hostnames tenant_id FK, unique on hostname
tenant_bots Telegram bot token registrations (encrypted) tenant_id FK, unique on telegram_bot_id
tenant_integrations Catalog / delivery / payment adapter configs tenant_id FK
tenant_payment_policies Per-tenant payment rail configuration tenant_id FK, 1:1
tenant_user_roles User ↔ tenant role grants composite unique (tenant_id, user_id, role)

tenants

Column Type Constraints Default Description
id uuid PK gen_random_uuid() Tenant identifier.
owner_user_id uuid NOT NULL, FK → users.id RESTRICT Owner User (pgId). RESTRICT prevents silent orphan on user delete.
slug text NOT NULL, UNIQUE URL-safe label [a-z0-9-]{3,40} used for seller.amn.gg and /t/:slug.
type tenantType enum NOT NULL hosted_seller Tenant tier.
status tenantStatus enum NOT NULL pending Lifecycle state.
display_name text NOT NULL Human name for the shop.
billing_account_id text nullable External billing reference (no FK in Phase 0/1).
isolation_mode tenantIsolationMode enum NOT NULL shared Data isolation level.
shop_settings_id uuid nullable, FK → shop_settings.id SET NULL Link to existing ShopSettings row.
brand jsonb nullable { name?, logoUrl?, primaryColor?, supportEmail? } — drives bootstrap payload.
features jsonb nullable { escrowCheckout?, directCheckout?, externalPayments?, telegramMiniApp? } — overrides policy-derived flags.
locale_defaults text[] nullable e.g. ['en', 'fa'].
legacy_object_id text nullable Convention parity field; tenants are PG-native.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Indexes

Name Columns Type
tenants_slug_uq slug UNIQUE
tenants_owner_user_id_idx owner_user_id B-tree
tenants_status_idx status B-tree

Enums

Enum Values
tenantType hosted_seller, white_label, isolated, enterprise
tenantStatus pending, active, suspended, closed
tenantIsolationMode shared, schema, database, stack

tenant_domains

Column Type Constraints Default Description
id uuid PK gen_random_uuid()
tenant_id uuid NOT NULL, FK → tenants.id CASCADE Owning tenant.
hostname text NOT NULL, UNIQUE Full hostname e.g. shop.example.com. Globally unique — the resolution key.
mode tenantDomainMode enum NOT NULL cname How DNS is managed.
status tenantDomainStatus enum NOT NULL pending Domain lifecycle state.
verification_token text NOT NULL Random hex token for TXT/CNAME proof.
tls_status tenantTlsStatus enum NOT NULL pending TLS certificate state.
last_checked_at timestamptz nullable Last validation probe.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Enums

Enum Values
tenantDomainMode managed_ns, cname
tenantDomainStatus pending, active, degraded, suspended, removed
tenantTlsStatus pending, issued, failed, expired

[!warning] Hostname uniqueness is the security boundary A single hostname MUST map to at most one tenant. The unique index tenant_domains_hostname_uq enforces this. Code in tenantResolutionMiddleware relies on findByHostname returning at most one row.


tenant_bots

Column Type Constraints Default Description
id uuid PK gen_random_uuid()
tenant_id uuid NOT NULL, FK → tenants.id CASCADE Owning tenant.
telegram_bot_id text NOT NULL, UNIQUE Numeric Telegram bot id stored as text (exceeds JS safe int).
username text NOT NULL Bot @username.
encrypted_token text NOT NULL AES-256-GCM ciphertext of the BotFather token.
encrypted_token_iv text NOT NULL GCM IV (base64).
encrypted_token_tag text NOT NULL GCM auth tag (base64).
webhook_secret text NOT NULL Per-bot random hex webhook path secret used by /api/telegram/tenant-webhook/:botId.
status tenantBotStatus enum NOT NULL pending Bot lifecycle.
mini_app_url text nullable Telegram Mini App URL when configured.
claim_token text nullable One-time Telegram /start <token> deep-link token for the first admin claim.
admin_telegram_user_id text nullable Telegram user id that claimed the bot admin role.
last_webhook_at timestamptz nullable Last received webhook update.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Enums

Enum Values
tenantBotStatus pending, active, suspended, revoked

[!warning] Token fields encrypted_token, encrypted_token_iv, and encrypted_token_tag are AES-256-GCM fields. The repository layer never decrypts them. Decryption belongs exclusively to tenantBotService. Never include these columns or webhook_secret in API responses.

[!note] Claim flow New bots start as pending with a claim_token. The public service response exposes only a derived claimUrl while the bot is pending. When Telegram sends /start <claimToken> to /api/telegram/tenant-webhook/:botId with the correct Telegram webhook secret header, tenantBotService.claimAdmin() stores admin_telegram_user_id and flips the bot to active.


tenant_integrations

Column Type Constraints Default Description
id uuid PK gen_random_uuid()
tenant_id uuid NOT NULL, FK → tenants.id CASCADE
kind tenantIntegrationKind enum NOT NULL Integration category.
provider text NOT NULL Free-form provider slug e.g. shopify, http_json.
status tenantIntegrationStatus enum NOT NULL draft Integration lifecycle.
config jsonb nullable Non-secret config blob.
encrypted_config text nullable AES-GCM ciphertext for provider keys/secrets.
encrypted_config_iv text nullable GCM IV.
encrypted_config_tag text nullable GCM auth tag.
last_sync_at timestamptz nullable
last_error text nullable Last sync error message.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Unique index: tenant_integrations_tenant_kind_provider_uq on (tenant_id, kind, provider).

Enums

Enum Values
tenantIntegrationKind catalog, delivery, payment, accounting, notification
tenantIntegrationStatus draft, active, error, disabled

tenant_payment_policies

1:1 with tenants (enforced by unique index on tenant_id). Created automatically with amn_escrow defaults when a tenant is created.

Column Type Constraints Default Description
id uuid PK gen_random_uuid()
tenant_id uuid NOT NULL, FK → tenants.id CASCADE, UNIQUE Owning tenant (1:1).
allowed_rails tenantPaymentRail[] NOT NULL ARRAY['amn_escrow'] PG enum array of permitted payment rails.
default_rail tenantPaymentRail NOT NULL amn_escrow Rail used when buyer doesn't specify. CHECK: must be in allowed_rails.
escrow_required_above_amount numeric(38,18) nullable Orders above this amount force amn_escrow. Matches payments.amount precision.
escrow_required_for_categories text[] nullable Category slugs that always require escrow.
buyer_disclosure_mode tenantBuyerDisclosureMode NOT NULL strict How prominently the non-escrow notice is shown to buyers.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Enums

Enum Values
tenantPaymentRail amn_escrow, amn_direct, external_provider, manual_invoice
tenantBuyerDisclosureMode plain, strict

[!note] CHECK constraint tenant_payment_policies_default_in_allowed_ck enforces default_rail = ANY(allowed_rails) at the DB level. Route validation mirrors this at the application level.


tenant_user_roles

Column Type Constraints Default Description
id uuid PK gen_random_uuid()
tenant_id uuid NOT NULL, FK → tenants.id CASCADE
user_id uuid NOT NULL, FK → users.id CASCADE users.id (Postgres UUID, i.e. pgId).
role tenantUserRole enum NOT NULL Role within the tenant.
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()

Unique index: tenant_user_roles_tenant_user_role_uq on (tenant_id, user_id, role) — a user may hold each role at most once per tenant.

Enum

Enum Values
tenantUserRole owner, manager, finance, support, developer

State transitions

Tenant status

stateDiagram-v2
    [*] --> pending : createTenant()
    pending --> active : operator activateTenant()
    active --> suspended : operator suspendTenant()
    suspended --> active : operator activateTenant()
    active --> closed : operator (Phase 2+)
    pending --> closed : operator rejects

Domain status

stateDiagram-v2
    [*] --> pending : POST /domains
    pending --> active : DNS verified + Caddy route added
    active --> active : TLS pending/issued
    pending --> degraded : Caddy provisioning fails
    degraded --> active : probe recovers
    active --> suspended : DELETE /domains/:domainId
    suspended --> removed : future cleanup

Key relationships

erDiagram
    users ||--o{ tenants : "owns (ownerUserId)"
    shop_settings ||--o| tenants : "linked (shopSettingsId)"
    tenants ||--o{ tenant_domains : "has"
    tenants ||--o{ tenant_bots : "has"
    tenants ||--o{ tenant_integrations : "has"
    tenants ||--|| tenant_payment_policies : "has (1:1)"
    tenants ||--o{ tenant_user_roles : "grants"
    users ||--o{ tenant_user_roles : "receives"

Common queries

// Resolve tenant from HTTP Host header (via service)
const result = await tenantService.resolveTenantByHost(req.hostname);
// result?.tenant or null

// Find active domain
const domain = await getTenantDomainRepo().findByHostname('shop.example.com');
// domain.status must be 'active' before trusting

// Build bootstrap payload for public storefront
const payload = await tenantService.buildBootstrapPayload(tenant);

// Check user roles in tenant
const roles = await getTenantUserRoleRepo().findRolesForUserInTenant(tenantId, userId);

// Upsert payment policy (idempotent)
await getTenantPaymentPolicyRepo().upsertForTenant(tenantId, { allowedRails, defaultRail });

Migration

Tables are PG-native — no Mongo backfill path. Run:

cd backend && npx drizzle-kit generate
# review the generated SQL
npx drizzle-kit migrate

Related: PRD - Seller-Owned White-Label Shops and Bots, ShopSettings, User, Payment.