-- ===================================================================== -- IDENTITY SCHEMA — Part 1: Tenants (Multi-tenancy / Reseller API) -- ===================================================================== -- Every user, project, render, export belongs to a tenant. -- FlatRender's own customers belong to the DEFAULT tenant. -- Resellers (B2B) are their own tenants with their own users. -- ===================================================================== SET search_path TO identity, public; -- --------------------------------------------------------------------- -- tenants — companies / resellers (and FlatRender itself = default) -- --------------------------------------------------------------------- CREATE TYPE tenant_status AS ENUM ('Active','Trial','Suspended','Cancelled'); CREATE TYPE tenant_kind AS ENUM ('Internal','Reseller','Enterprise'); CREATE TABLE tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug CITEXT UNIQUE NOT NULL, -- 'flatrender', 'acme', ... name TEXT NOT NULL, kind tenant_kind NOT NULL DEFAULT 'Reseller', status tenant_status NOT NULL DEFAULT 'Trial', -- Domains custom_domain CITEXT UNIQUE, -- videos.acme.com domain_verified BOOLEAN NOT NULL DEFAULT FALSE, allowed_origins TEXT[] NOT NULL DEFAULT '{}', -- CORS -- Contact contact_name TEXT, contact_email CITEXT, contact_phone TEXT, billing_email CITEXT, -- Limits (overrideable from default plan) max_users INT, -- NULL = unlimited max_storage_gb INT, monthly_render_qty INT, monthly_render_sec INT, -- Lifecycle trial_ends_at TIMESTAMPTZ, suspended_at TIMESTAMPTZ, suspension_reason TEXT, -- Metadata metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_tenants_status ON tenants(status) WHERE deleted_at IS NULL; CREATE INDEX idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL; CREATE TRIGGER tg_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- Seed the default (internal) FlatRender tenant INSERT INTO tenants (id, slug, name, kind, status) VALUES ( '00000000-0000-0000-0000-000000000001', 'flatrender', 'FlatRender', 'Internal', 'Active' ); -- --------------------------------------------------------------------- -- tenant_branding — white-label appearance -- --------------------------------------------------------------------- CREATE TABLE tenant_branding ( tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, -- Identity display_name TEXT, logo_url TEXT, logo_dark_url TEXT, favicon_url TEXT, og_image_url TEXT, -- Theme primary_color TEXT NOT NULL DEFAULT '#3B82F6', secondary_color TEXT NOT NULL DEFAULT '#8B5CF6', accent_color TEXT, background_color TEXT, font_family TEXT, -- Email email_from_name TEXT, email_from_address CITEXT, email_reply_to CITEXT, email_footer_html TEXT, -- Links support_url TEXT, terms_url TEXT, privacy_url TEXT, -- Studio embed embed_enabled BOOLEAN NOT NULL DEFAULT FALSE, embed_allowed_hosts TEXT[] NOT NULL DEFAULT '{}', -- Custom CSS for advanced clients (sanitized) custom_css TEXT, -- Watermark for free renders (reseller can override) watermark_text TEXT, watermark_image_url TEXT, watermark_enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TRIGGER tg_tenant_branding_updated_at BEFORE UPDATE ON tenant_branding FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- --------------------------------------------------------------------- -- tenant_settings — feature flags + integration config -- --------------------------------------------------------------------- CREATE TABLE tenant_settings ( tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, -- Locale default_locale TEXT NOT NULL DEFAULT 'fa', supported_locales TEXT[] NOT NULL DEFAULT '{fa,en}', default_currency TEXT NOT NULL DEFAULT 'IRR', -- Payments allowed for this tenant's users payment_gateways TEXT[] NOT NULL DEFAULT '{ZarinPal}', -- {ZarinPal,IdPay,Bazaar,Stripe} -- Render max_resolution TEXT NOT NULL DEFAULT 'FullHD', -- HD/FullHD/FourK max_duration_sec INT NOT NULL DEFAULT 600, allow_4k BOOLEAN NOT NULL DEFAULT FALSE, allow_voiceover BOOLEAN NOT NULL DEFAULT TRUE, allow_music_visualizer BOOLEAN NOT NULL DEFAULT TRUE, allow_mockup BOOLEAN NOT NULL DEFAULT TRUE, -- Webhooks webhook_signing_secret TEXT, -- Feature flags features JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TRIGGER tg_tenant_settings_updated_at BEFORE UPDATE ON tenant_settings FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- --------------------------------------------------------------------- -- tenant_api_keys — server-to-server API access -- --------------------------------------------------------------------- CREATE TYPE api_key_environment AS ENUM ('Live','Test'); CREATE TABLE tenant_api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name TEXT NOT NULL, -- "Production server" environment api_key_environment NOT NULL DEFAULT 'Live', -- Storage key_prefix TEXT NOT NULL, -- "fr_live_abc..." (first 12 chars visible) key_hash TEXT NOT NULL, -- SHA-256 of full key last4 TEXT NOT NULL, -- last 4 chars for UI -- Permissions scopes TEXT[] NOT NULL DEFAULT '{}', -- ["renders:create","projects:read",...] allowed_ips INET[], -- NULL = any rate_limit_rpm INT NOT NULL DEFAULT 600, -- Lifecycle is_active BOOLEAN NOT NULL DEFAULT TRUE, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoked_reason TEXT, last_used_at TIMESTAMPTZ, last_used_ip INET, usage_count BIGINT NOT NULL DEFAULT 0, created_by_user_id UUID, -- FK added after users table exists created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX uq_api_keys_hash ON tenant_api_keys(key_hash); CREATE INDEX idx_api_keys_tenant ON tenant_api_keys(tenant_id) WHERE is_active = TRUE; CREATE INDEX idx_api_keys_prefix ON tenant_api_keys(key_prefix); CREATE TRIGGER tg_tenant_api_keys_updated_at BEFORE UPDATE ON tenant_api_keys FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- --------------------------------------------------------------------- -- tenant_webhooks — outbound events to reseller systems -- --------------------------------------------------------------------- CREATE TABLE tenant_webhooks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name TEXT NOT NULL, url TEXT NOT NULL, events TEXT[] NOT NULL, -- ["render.completed","render.failed",...] secret TEXT NOT NULL, -- HMAC signing secret is_active BOOLEAN NOT NULL DEFAULT TRUE, last_triggered_at TIMESTAMPTZ, last_status_code INT, consecutive_failures INT NOT NULL DEFAULT 0, disabled_at TIMESTAMPTZ, -- auto-disable after N failures created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_webhooks_tenant_active ON tenant_webhooks(tenant_id) WHERE is_active = TRUE; CREATE TRIGGER tg_tenant_webhooks_updated_at BEFORE UPDATE ON tenant_webhooks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- --------------------------------------------------------------------- -- tenant_webhook_deliveries — log of attempts -- --------------------------------------------------------------------- CREATE TABLE tenant_webhook_deliveries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), webhook_id UUID NOT NULL REFERENCES tenant_webhooks(id) ON DELETE CASCADE, tenant_id UUID NOT NULL, -- denormalized for partitioning event_type TEXT NOT NULL, event_id UUID NOT NULL, payload JSONB NOT NULL, request_url TEXT NOT NULL, response_status INT, response_body TEXT, duration_ms INT, attempt INT NOT NULL DEFAULT 1, succeeded BOOLEAN NOT NULL DEFAULT FALSE, next_retry_at TIMESTAMPTZ, error_message TEXT, delivered_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_webhook_deliveries_webhook ON tenant_webhook_deliveries(webhook_id, created_at DESC); CREATE INDEX idx_webhook_deliveries_pending ON tenant_webhook_deliveries(next_retry_at) WHERE succeeded = FALSE AND next_retry_at IS NOT NULL; -- --------------------------------------------------------------------- -- tenant_usage_daily — aggregate metering for billing the reseller -- --------------------------------------------------------------------- CREATE TABLE tenant_usage_daily ( id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, usage_date DATE NOT NULL, -- Render renders_started INT NOT NULL DEFAULT 0, renders_completed INT NOT NULL DEFAULT 0, renders_failed INT NOT NULL DEFAULT 0, render_seconds BIGINT NOT NULL DEFAULT 0, -- output duration sum render_compute_sec BIGINT NOT NULL DEFAULT 0, -- node compute time -- Storage storage_bytes BIGINT NOT NULL DEFAULT 0, -- snapshot at end of day -- API api_calls BIGINT NOT NULL DEFAULT 0, api_4xx BIGINT NOT NULL DEFAULT 0, api_5xx BIGINT NOT NULL DEFAULT 0, -- Users active_users INT NOT NULL DEFAULT 0, -- DAU new_users INT NOT NULL DEFAULT 0, -- Billing amount_billed NUMERIC(14,2) NOT NULL DEFAULT 0, billing_currency TEXT NOT NULL DEFAULT 'IRR', billing_status TEXT NOT NULL DEFAULT 'Pending', -- Pending/Invoiced/Paid created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (tenant_id, usage_date) ); CREATE INDEX idx_tenant_usage_tenant_date ON tenant_usage_daily(tenant_id, usage_date DESC); CREATE TRIGGER tg_tenant_usage_updated_at BEFORE UPDATE ON tenant_usage_daily FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); -- --------------------------------------------------------------------- -- tenant_api_request_logs — per-call audit (partitioned by month) -- --------------------------------------------------------------------- CREATE TABLE tenant_api_request_logs ( id BIGSERIAL, tenant_id UUID NOT NULL, api_key_id UUID, method TEXT NOT NULL, path TEXT NOT NULL, status_code INT NOT NULL, duration_ms INT NOT NULL, request_id UUID NOT NULL, user_id UUID, ip_address INET, user_agent TEXT, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at); CREATE INDEX idx_api_logs_tenant ON tenant_api_request_logs(tenant_id, created_at DESC); CREATE INDEX idx_api_logs_key ON tenant_api_request_logs(api_key_id, created_at DESC); -- Initial partition (ops creates monthly going forward) CREATE TABLE tenant_api_request_logs_y2026m01 PARTITION OF tenant_api_request_logs FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); -- --------------------------------------------------------------------- -- tenant_domain_verifications — DNS / file verification -- --------------------------------------------------------------------- CREATE TABLE tenant_domain_verifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, domain CITEXT NOT NULL, method TEXT NOT NULL, -- 'DNS_TXT' or 'HTTP_FILE' challenge_token TEXT NOT NULL, verified BOOLEAN NOT NULL DEFAULT FALSE, verified_at TIMESTAMPTZ, last_checked_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_domain_verif_tenant ON tenant_domain_verifications(tenant_id);