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>
This commit is contained in:
289
02 - Data Models/Tenant.md
Normal file
289
02 - Data Models/Tenant.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
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]].
|
||||
Reference in New Issue
Block a user