Arquitetura Multi-Tenant
x17 usa o modelo shared database, shared schema com isolamento via:
- Coluna
account_idem todas as tabelas de dados - Row Level Security (RLS) no PostgreSQL
- Scoping no BaseRepository
Cada “tenant” é uma Account (conta). Um usuário pode pertencer a múltiplas contas via account_users (tabela associativa).
Tabelas e account_id
Seção intitulada “Tabelas e account_id”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):
contactsconversationstagscontact_tags(via join)message_templatesautomationsautomation_stepsautomation_executionsautomation_execution_stepsautomation_execution_suspensionscampaignscampaign_sendswebhookswebhook_logsai_settingsbrand_contextwhatsapp_profilesquick_repliesecommerce_integrationsecommerce_ordersecommerce_abandoned_cartssegmentssegment_memberseventsmetricscontact_metrics_summarytracked_linkslink_clicksaudit_logsnotificationsfolderssubscriptionsbilling_historysmart_sending_timersshipment_trackingsshipment_tracking_eventsad_spendscontact_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.
Row Level Security (RLS)
Seção intitulada “Row Level Security (RLS)”Todas as tabelas com account_id possuem RLS policies que:
- Filtram
SELECTporaccount_iddo usuário autenticado - Filtram
INSERT/UPDATE/DELETEporaccount_id - Filtram automaticamente registros com
deleted_at IS NULLnos 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.
BaseRepository - Scoping por Account
Seção intitulada “BaseRepository - Scoping por Account”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 )}Operações scoped:
Seção intitulada “Operações scoped:”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 } : inputupdate(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.
Repositórios Especializados
Seção intitulada “Repositórios Especializados”Todos os repositórios herdam de BaseRepository com accountScoped = true exceto:
| Repositório | accountScoped | Motivo |
|---|---|---|
| ContactRepository | true | Padrão |
| MessageRepository | false | Messages são scoped via conversation_id |
| Todos os outros | true | Padrão |
Admin Client e Bypass de RLS
Seção intitulada “Admin Client e Bypass de RLS”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
eventssem contexto de request
IMPORTANTE: O admin client SEMPRE específica account_id nas queries manualmente, garantindo isolamento mesmo sem RLS.
Account Switching
Seção intitulada “Account Switching”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.
Modelo de Dados de Conta
Seção intitulada “Modelo de Dados de Conta”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)Soft Delete
Seção intitulada “Soft Delete”Tabelas com soft delete (possuem coluna deleted_at):
contactsautomationscampaignsmessage_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>)Isolamento de WhatsApp
Seção intitulada “Isolamento de WhatsApp”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:
- Meta envia payload com
metadata.phone_number_id - Lookup em
whatsapp_profilespara encontraraccount_id - Processamento scoped para esse account
Arquivos Relevantes
Seção intitulada “Arquivos Relevantes”| Arquivo | Descrição |
|---|---|
src/lib/repositories/base.ts | BaseRepository com scoping |
src/lib/repositories/index.ts | Todos os repositórios especializados |
src/lib/api/auth.ts | getAuthContext() com account switching |
src/lib/supabase/server.ts | Server client com header x-active-account-id |
src/lib/supabase/admin.ts | Admin client (bypass RLS) |
src/types/database.ts | Tipos: Account, AccountUser, AccountRole |