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

290 lines
12 KiB
Markdown

---
title: Tenant
tags: [data-model, postgres, drizzle, white-label, multi-tenant]
aliases: [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
```mermaid
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
```mermaid
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
```mermaid
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
```ts
// 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:
```bash
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]].