Pular para o conteúdo

Arquitetura Multi-Tenant

x17 usa o modelo shared database, shared schema com isolamento via:

  1. Coluna account_id em todas as tabelas de dados
  2. Row Level Security (RLS) no PostgreSQL
  3. Scoping no BaseRepository

Cada “tenant” é uma Account (conta). Um usuário pode pertencer a múltiplas contas via account_users (tabela associativa).

Toda tabela que armazena dados de clientes contém a coluna account_id UUID NOT NULL REFERENCES accounts(id).

Tabelas com account_id (não exaustivo):

  • contacts
  • conversations
  • tags
  • contact_tags (via join)
  • message_templates
  • automations
  • automation_steps
  • automation_executions
  • automation_execution_steps
  • automation_execution_suspensions
  • campaigns
  • campaign_sends
  • webhooks
  • webhook_logs
  • ai_settings
  • brand_context
  • whatsapp_profiles
  • quick_replies
  • ecommerce_integrations
  • ecommerce_orders
  • ecommerce_abandoned_carts
  • segments
  • segment_members
  • events
  • metrics
  • contact_metrics_summary
  • tracked_links
  • link_clicks
  • audit_logs
  • notifications
  • folders
  • subscriptions
  • billing_history
  • smart_sending_timers
  • shipment_trackings
  • shipment_tracking_events
  • ad_spends
  • contact_rfm_scores

Exceção: messages NAO tem account_id diretamente. O isolamento é via conversation_id (que pertence a um account_id). O MessageRepository tem accountScoped = false.

Todas as tabelas com account_id possuem RLS policies que:

  1. Filtram SELECT por account_id do usuário autenticado
  2. Filtram INSERT/UPDATE/DELETE por account_id
  3. Filtram automaticamente registros com deleted_at IS NULL nos SELECTs (soft delete)

O Supabase client server-side envia o header x-active-account-id para que as policies possam filtrar corretamente quando o usuário tem múltiplas contas.

O BaseRepository<T> (src/lib/repositories/base.ts) implementa o isolamento na camada de aplicação:

abstract class BaseRepository<T extends { id: string }> {
constructor(
protected readonly table: string,
protected readonly accountScoped = true // default: true
)
}

findById(id, accountId)

let query = supabase.from(this.table).select("*").eq("id", id)
if (this.accountScoped && accountId) {
query = query.eq("account_id", accountId)
}

findAll(accountId, págination, filters)

let query = supabase.from(this.table).select("*", { count: "exact" })
if (this.accountScoped) {
query = query.eq("account_id", accountId)
}

create(accountId, input)

const payload = this.accountScoped ? { ...input, account_id: accountId } : input

update(id, accountId, input)

let query = supabase.from(this.table).update(input).eq("id", id)
if (this.accountScoped) {
query = query.eq("account_id", accountId)
}

delete(id, accountId) e softDelete(id, accountId) Mesmo padrão: adiciona .eq("account_id", accountId) quando scoped.

Todos os repositórios herdam de BaseRepository com accountScoped = true exceto:

RepositórioaccountScopedMotivo
ContactRepositorytruePadrão
MessageRepositoryfalseMessages são scoped via conversation_id
Todos os outrostruePadrão

O createAdminClient() usa a SUPABASE_SERVICE_ROLE_KEY que bypassa RLS completamente. Usado em:

  • Webhook processing: Não há usuário autenticado, precisa acessar dados por phone_number_id
  • Automation execution: Background jobs que processam dados de qualquer account
  • Cron jobs: Executam sem contexto de usuário
  • Audit logging: Fire-and-forget que não deve bloquear a response
  • Event emission: Insercao em tabela events sem contexto de request

IMPORTANTE: O admin client SEMPRE específica account_id nas queries manualmente, garantindo isolamento mesmo sem RLS.

Usuários podem pertencer a múltiplas contas. O switch funciona via cookie:

Cookie: x17-active-account-id=<uuid>

O getAuthContext() le esse cookie e válida que o usuário pertence a conta:

const { data: accountUser } = await supabase
.from("account_users")
.select("account_id, role")
.eq("user_id", user.id)
.eq("account_id", activeAccountId)
.single()

Se o cookie não existe ou e inválido, retorna a primeira conta do usuário.

accounts
id UUID PK
name TEXT
slug TEXT UNIQUE
logo_url TEXT
plan (free|starter|pro|enterprise)
status (active|suspended|cancelled)
smart_sending_enabled BOOLEAN
smart_sending_window_hours INTEGER
quiet_hours_enabled BOOLEAN
quiet_hours_start TIME
quiet_hours_end TIME
timezone TEXT
created_at TIMESTAMPTZ
account_users (tabela associativa)
id UUID PK
account_id UUID FK -> accounts
user_id UUID FK -> auth.users
role (owner|admin|member|viewer|agent)
created_at TIMESTAMPTZ
UNIQUE(account_id, user_id)

Tabelas com soft delete (possuem coluna deleted_at):

  • contacts
  • automations
  • campaigns
  • message_templates

O softDelete() faz UPDATE deleted_at = NOW() em vez de DELETE. RLS policies filtram automaticamente deleted_at IS NULL.

Nota: O TypeScript type em database.ts NAO inclui deleted_at, então o softDelete() usa cast:

.update({ deleted_at: new Date().toISOString() } as Record<string, unknown>)

Cada conta conecta seu próprio número WhatsApp via Facebook Embedded Signup. O perfil é armazenado em whatsapp_profiles:

whatsapp_profiles
account_id UUID FK -> accounts
phone_number_id TEXT (ID do Meta)
waba_id TEXT (WhatsApp Business Account ID)
access_token_encrypted TEXT (AES-256-GCM)
status (connected|disconnected)

O webhook WhatsApp roteia por phone_number_id:

  1. Meta envia payload com metadata.phone_number_id
  2. Lookup em whatsapp_profiles para encontrar account_id
  3. Processamento scoped para esse account
ArquivoDescrição
src/lib/repositories/base.tsBaseRepository com scoping
src/lib/repositories/index.tsTodos os repositórios especializados
src/lib/api/auth.tsgetAuthContext() com account switching
src/lib/supabase/server.tsServer client com header x-active-account-id
src/lib/supabase/admin.tsAdmin client (bypass RLS)
src/types/database.tsTipos: Account, AccountUser, AccountRole