Files
flatrender/backend/db/migrations/01_identity_tenants.sql
T
soroush.asadi 90ac0b81d1 feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:29:31 +03:30

353 lines
14 KiB
SQL

-- =====================================================================
-- 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);