freemium F1: enforcement de limite de pacientes (schema-per-tenant)
- therapist_free ganha max_patients=20 (clinic_free ja tinha 30) - trigger BEFORE INSERT em patients le plan_features.limits em runtime, resolve tenant por TG_TABLE_SCHEMA, plano ativo (clinica via tenant_id + pessoal via owner user_id), conta vivos (status<>Arquivado) e da RAISE PLAN_LIMIT_REACHED|patients|<n> - helpers tenant_active_plan_id / plan_feature_limit (globais, sobrevivem F6.3) - wiring: tenants novos ganham via trg_attach_business_triggers; 9 existentes backfill - testado: clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado (rollback) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+150
-338
@@ -1,338 +1,150 @@
|
||||
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
|
||||
Contexto e objetivo
|
||||
|
||||
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
|
||||
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
|
||||
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
|
||||
|
||||
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
|
||||
considerando as lições que aprendi.
|
||||
|
||||
Antes de começar — varredura obrigatória
|
||||
|
||||
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
|
||||
|
||||
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
|
||||
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
|
||||
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
|
||||
SELECT table_name,
|
||||
EXISTS(SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id') AS has_tenant_id
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
|
||||
"29 funções" e eram na verdade 52. Use:
|
||||
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
|
||||
SELECT DISTINCT p.proname, p.prokind, l.lanname
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
JOIN pg_language l ON l.oid = p.prolang
|
||||
CROSS JOIN tenant_tabs t
|
||||
WHERE n.nspname='public'
|
||||
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
|
||||
ORDER BY 1;
|
||||
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
|
||||
cuidado especial.
|
||||
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
|
||||
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
|
||||
|
||||
Plano de execução em fases
|
||||
|
||||
F0 — Categorização (não codar nada ainda)
|
||||
|
||||
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
|
||||
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
|
||||
|
||||
F1 — Template + helpers
|
||||
|
||||
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
|
||||
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
|
||||
- Crie helpers em public:
|
||||
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
|
||||
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
|
||||
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
|
||||
tenant_id (porque a coluna não existe mais nas tabelas tenant).
|
||||
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
|
||||
dele.
|
||||
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
|
||||
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
|
||||
|
||||
F2 — Provisionamento
|
||||
|
||||
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
|
||||
tenant novo.
|
||||
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
|
||||
por profiles.tenant_id = '<id-do-tenant>').
|
||||
|
||||
F3 — Frontend: composable de acesso tenant
|
||||
|
||||
- Crie useTenantDb.js:
|
||||
export function useTenantDb() {
|
||||
const { perfil } = useAuth();
|
||||
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
|
||||
const isReady = computed(() => Boolean(schemaName.value));
|
||||
function db() {
|
||||
if (!schemaName.value) throw new Error('tenant não disponível');
|
||||
return supabase.schema(schemaName.value);
|
||||
}
|
||||
return { db, schemaName, isReady };
|
||||
}
|
||||
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
|
||||
views/components/composables que tocam tabelas tenant.
|
||||
|
||||
F4 — Edge functions
|
||||
|
||||
Padrão pra qualquer edge function que precisa acessar tabela tenant:
|
||||
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
});
|
||||
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
|
||||
const tenantDb = userClient.schema(tenantSchema as string);
|
||||
await tenantDb.from('oficios').update(...).eq(...);
|
||||
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
|
||||
|
||||
F5 — Expor schemas no PostgREST
|
||||
|
||||
Edite supabase/config.toml:
|
||||
[api]
|
||||
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
|
||||
migration que regenera config.toml, ou aceite gerenciamento manual.
|
||||
|
||||
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
|
||||
|
||||
Divida em lotes pequenos e teste cada um:
|
||||
|
||||
Lote 1 — split de notifications
|
||||
|
||||
Caso especial crítico. Antes do split, identifique:
|
||||
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
|
||||
system_alert global).
|
||||
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
|
||||
|
||||
Decisão estrutural: notifications precisa virar duas tabelas:
|
||||
- tenant_<slug>.notifications — locais do tenant.
|
||||
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
|
||||
|
||||
Migration faz:
|
||||
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
|
||||
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
|
||||
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
|
||||
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
|
||||
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
|
||||
4. DROP TABLE public.notifications.
|
||||
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
|
||||
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
|
||||
|
||||
Lote 2-4 — refator das demais funções
|
||||
|
||||
Padrão pra TRIGGER em tabela tenant:
|
||||
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tenant_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
|
||||
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC chamada por user logado em um tenant:
|
||||
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text := public.current_tenant_schema();
|
||||
BEGIN
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
-- ... lógica ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
|
||||
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
|
||||
v_schema := public.tenant_schema_name(t_row.slug);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
|
||||
EXECUTE format('UPDATE %I.tabela ...', v_schema);
|
||||
END LOOP;
|
||||
|
||||
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
|
||||
USING ...;
|
||||
|
||||
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
|
||||
|
||||
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
|
||||
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
|
||||
|
||||
SET session_replication_role = replica; -- desabilita FK checks
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_id_target uuid := '...';
|
||||
tenant_schema text := 'tenant_...';
|
||||
tabs text[] := ARRAY[/* lista */];
|
||||
t text;
|
||||
v_cols text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
-- Lista colunas do schema tenant (sem tenant_id já)
|
||||
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = tenant_schema AND table_name = t;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
|
||||
ELSE
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- Reset sequences:
|
||||
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
|
||||
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
|
||||
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
|
||||
v_seq, r.column_name, tenant_schema, r.table_name);
|
||||
END LOOP;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Lote 5 — DROP CASCADE das tabelas em public
|
||||
|
||||
Só depois de TODAS as funções refatoradas e dados migrados:
|
||||
SET session_replication_role = replica;
|
||||
DO $$ BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Limitações conhecidas e workarounds
|
||||
|
||||
1. PostgREST não suporta embed FK cross-schema
|
||||
|
||||
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
|
||||
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
|
||||
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
|
||||
|
||||
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
|
||||
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
|
||||
if (!rows?.length) return rows;
|
||||
const allIds = new Set();
|
||||
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
|
||||
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
|
||||
const map = new Map((data || []).map(p => [p.id, p]));
|
||||
return rows.map(r => {
|
||||
const out = { ...r };
|
||||
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
|
||||
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
|
||||
|
||||
2. %ROWTYPE de tabelas tenant
|
||||
|
||||
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
|
||||
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
|
||||
jsonb_build_object(...).
|
||||
|
||||
3. SQL functions com SET search_path TO 'public' declarado
|
||||
|
||||
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
|
||||
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
|
||||
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
|
||||
|
||||
4. Triggers de notif que filtram cada destinatário
|
||||
|
||||
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
|
||||
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
|
||||
|
||||
5. Realtime
|
||||
|
||||
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
|
||||
supabase_realtime ADD TABLE public.notifications_sistema.
|
||||
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
|
||||
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
|
||||
|
||||
6. Filtros .eq('tenant_id', X) no frontend
|
||||
|
||||
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
|
||||
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
|
||||
public (tenant_features, tenant_audit_log, profiles).
|
||||
|
||||
7. session_replication_role na migração de dados
|
||||
|
||||
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
|
||||
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
|
||||
|
||||
8. Reset de sequences
|
||||
|
||||
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
|
||||
com PKs existentes.
|
||||
|
||||
9. Policies que usam funções refatoradas
|
||||
|
||||
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
|
||||
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
|
||||
policies de tabelas que ficam, recrie a policy depois.
|
||||
|
||||
10. FKs de tabelas que ficam em public apontando pras que saem
|
||||
|
||||
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
|
||||
converte FK pra coluna solta sem constraint.
|
||||
|
||||
Frontend — refactor sistemático
|
||||
|
||||
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
|
||||
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
|
||||
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
|
||||
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
|
||||
tenantSchemaName(slug).
|
||||
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
|
||||
|
||||
Backups e segurança
|
||||
|
||||
Sempre faça backup antes de cada lote:
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
|
||||
backups/pre-loteN/public.sql
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
|
||||
backups/pre-loteN/tenant_<slug>.sql
|
||||
|
||||
Pra recarregar cache do PostgREST após mudanças:
|
||||
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
|
||||
|
||||
Se mudou config.toml (schemas expostos), restart obrigatório:
|
||||
docker restart supabase_rest_<projeto>
|
||||
|
||||
Checklist final por lote
|
||||
|
||||
Antes de marcar um lote como concluído:
|
||||
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
|
||||
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
|
||||
- NOTIFY pgrst, 'reload schema' rodado
|
||||
- Usuário testou as telas do FE que tocam essas funções
|
||||
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
|
||||
|
||||
Como interagir comigo durante o trabalho
|
||||
|
||||
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
|
||||
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
|
||||
policy órfã), use perguntas múltipla escolha — não decida sozinho.
|
||||
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
|
||||
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
|
||||
- Backup antes de cada DROP destrutivo.
|
||||
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.
|
||||
---
|
||||
|
||||
# TAREFA: Implementar modelo freemium/PLG (plano gratuito self-service + Upgrade PRO)
|
||||
|
||||
Você vai transformar o caminho de aquisição de assinatura deste SaaS multi-tenant
|
||||
em um modelo freemium/PLG, igual ao que já fiz num sistema irmão. O objetivo:
|
||||
qualquer visitante cria uma conta gratuita sozinho, confirma o e-mail, e o ambiente
|
||||
do tenant é provisionado automaticamente — sem dev no meio. Plano gratuito limitado
|
||||
+ botão "Upgrade PRO" no topo.
|
||||
|
||||
IMPORTANTE: este sistema é PARECIDO mas NÃO idêntico ao de referência. NÃO assuma
|
||||
nomes de tabelas/funções/rotas. Antes de QUALQUER código, faça a fase de descoberta
|
||||
e me apresente o mapa + as decisões pra eu confirmar. Trabalhe em fases, commitando
|
||||
por assunto, e validando cada migration no banco local em transação com ROLLBACK
|
||||
antes de seguir. Rode o build a cada bloco de frontend.
|
||||
|
||||
## FASE 0 — DESCOBERTA (não codar ainda; me devolva um mapa com file:line)
|
||||
Mapeie e me explique como funciona hoje:
|
||||
1. Landing page / vitrine de planos e como o signup é acionado (query params? rota?).
|
||||
2. Fluxo de signup: componente, se usa supabase.auth.signUp direto ou um wrapper,
|
||||
o que cria (auth user, profile, tenant, subscription). Existe trigger
|
||||
handle_new_user em auth.users? Onde o profile nasce e com qual role default?
|
||||
3. Modelo de planos SaaS: tabelas (plans, plan_prices, plan_features, plan_limits,
|
||||
subscriptions, subscription_intents...), e o catálogo de features atual (LEIA
|
||||
DO BANCO, não de seeds antigos — o catálogo costuma divergir do seed inicial).
|
||||
4. Feature gating: como uma feature é checada (composable hasFeature? guard de
|
||||
rota com meta.feature? filtro de menu?).
|
||||
5. Enforcement de limites por plano: existe? (na maioria das vezes plan_limits
|
||||
está semeado mas NINGUÉM lê — confirme).
|
||||
6. Provisionamento de tenant: como um tenant nasce hoje (função provision_*?),
|
||||
é manual (dev) ou automático? É multi-tenant por RLS ou schema-per-tenant?
|
||||
Se schema-per-tenant: existe clone_tenant_schema/tenant_schema_name? O clone
|
||||
copia triggers do template?
|
||||
7. Fluxo de auth: onde o profile é carregado no login (carregarPerfil?), onde o
|
||||
guard decide pra onde mandar o usuário (roleHomePath), e o que acontece com um
|
||||
usuário logado SEM tenant.
|
||||
8. Infra de e-mail: como e-mails transacionais são enviados (Resend? SMTP? edge
|
||||
function?). Existe tabela de templates + algum render de {{var}}? O e-mail do
|
||||
GoTrue (confirmação) funciona? Existe pg_net?
|
||||
9. Infra de billing/pagamento (AsaaS/Stripe?): existe checkout de assinatura
|
||||
RECORRENTE em nível de plano, ou só cobrança avulsa? Onde está o webhook?
|
||||
|
||||
## FASE 0.5 — DECISÕES (me apresente como perguntas; estes são os defaults que
|
||||
## funcionaram bem, com o porquê):
|
||||
- Provisionamento: AUTO, mas só DEPOIS de confirmar o e-mail (anti-spam: cada
|
||||
signup pode clonar dezenas de tabelas).
|
||||
- Funil: manter os dois caminhos (free self-service + pago via intent/comercial).
|
||||
- Upgrade PRO: checkout self-service (reusar infra de pagamento existente) — mas
|
||||
isso é FASE 3, deferida; no início o botão abre o canal comercial.
|
||||
- Trial: o "free para sempre" substitui o trial.
|
||||
- No limite: BLOQUEIA a inserção no banco (trigger) + toast amigável com CTA.
|
||||
- Slug do sindicato: a pessoa escolhe (sugestão automática a partir do nome,
|
||||
sanitizado), com checagem de disponibilidade ao vivo, e é IMUTÁVEL (se for
|
||||
schema-per-tenant, o slug É o nome do schema → trocar órfã tudo; trave em 3
|
||||
camadas: sem UI, guard no banco rejeitando UPDATE, validação na criação).
|
||||
|
||||
## FASE 1 — Fundação do plano gratuito
|
||||
1. Migration: criar plano `gratuito` (preço 0) + plan_features (tudo ON menos o
|
||||
módulo premium, ex: ordem_de_servico) + plan_limits (ex: 50 associados).
|
||||
REGRA DE OURO: referencie features POR KEY via subquery, NUNCA por uuid
|
||||
hardcoded (uuids de features geradas em runtime divergem entre ambientes).
|
||||
Deixe o plano OCULTO na vitrine nesta fase (self-service ainda não existe).
|
||||
2. Enforcement de limite GENÉRICO: uma função trigger que resolve o tenant pelo
|
||||
contexto (no schema-per-tenant: pelo nome do schema = TG_TABLE_SCHEMA; no
|
||||
RLS: pelo tenant_id), lê o plano ativo + plan_limits EM RUNTIME (pra mudar o
|
||||
número no painel valer sem deploy), conta linhas vivas e dá RAISE com um código
|
||||
parseável tipo 'PLAN_LIMIT_REACHED|<feature>|<limite>'. Trigger BEFORE INSERT
|
||||
na tabela limitada. Se schema-per-tenant: coloque no template E faça backfill
|
||||
nos schemas já existentes. Teste: 50 passam, 51º bloqueia; tenant pago intacto.
|
||||
3. Frontend: helper que traduz o erro PLAN_LIMIT_REACHED em toast amigável com
|
||||
CTA de upgrade, usado em TODOS os pontos de insert da tabela limitada. Botão
|
||||
"Upgrade PRO" no topbar quando o plano do tenant for 'gratuito'.
|
||||
|
||||
## FASE 2 — Self-service com confirmação de e-mail
|
||||
1. LIGUE a confirmação de e-mail (enable_confirmations=true no config.toml E no
|
||||
dashboard do hosted).
|
||||
2. ⚠️ PEGADINHA CRÍTICA #1: com confirmação ligada, o signup NÃO tem sessão. Então
|
||||
TUDO que dependia de auth.uid()/JWT no signup QUEBRA em silêncio:
|
||||
- inserir subscription_intents (RLS exige jwt email = email da linha) → erro.
|
||||
- registrar aceite legal (LGPD) → não grava.
|
||||
SOLUÇÃO: NÃO faça esses efeitos no signup. Grave a escolha (plan_key, interval,
|
||||
nome/slug do sindicato, ids das versões legais aceitas) no raw_user_meta_data
|
||||
do signUp, e processe TUDO no 1º login pós-confirmação, via RPCs idempotentes:
|
||||
- auto_provision_free_tenant() (lê metadata, cria tenant, provisiona, vira
|
||||
master, cria subscription gratuita ativa) — chamada em carregarPerfil quando
|
||||
o usuário não tem tenant. Gratuito não gera intenção.
|
||||
- processar_pos_signup() (aceite legal + cria a intenção SÓ pro caminho pago).
|
||||
3. ⚠️ PEGADINHA CRÍTICA #2 (segurança): após o signUp, se NÃO veio sessão
|
||||
(confirmação pendente), ENCERRE qualquer sessão local (signOut scope:'local')
|
||||
e mostre uma tela "confirme seu e-mail". Senão, uma sessão anterior (ex: dev
|
||||
testando) vaza e o push pra /login joga o usuário pro painel da sessão antiga.
|
||||
A pessoa só pode logar APÓS clicar no link do e-mail.
|
||||
4. ⚠️ PEGADINHA CRÍTICA #3 (blindagem): um usuário logado SEM tenant nunca pode
|
||||
cair num painel quebrado. No guard, redirecione todo logado-sem-tenant (não-dev)
|
||||
pra uma tela /onboarding que resolve os estados: provisionando, slug colidiu
|
||||
(deixa escolher outro slug e finalizar — faça o auto_provision aceitar um
|
||||
p_slug_override), conta paga aguardando ativação, sem acesso, erro (retry).
|
||||
5. Signup coleta nome do sindicato + slug (sugestão + sanitização + disponibilidade
|
||||
ao vivo via RPC slug_disponivel que retorna {ok, motivo}) + "seu nome".
|
||||
Torne o plano gratuito visível na vitrine agora.
|
||||
6. E-mail de boas-vindas: edge function (Resend) que renderiza o template, disparada
|
||||
no provisionamento. Best-effort (não bloqueia o login). Destinatário derivado
|
||||
do JWT, não do body.
|
||||
|
||||
## SAAS / EXTRAS (faça os que fizerem sentido)
|
||||
- Página /saas/usuarios: 1 linha por tenant com o DONO (master) — nome, slug,
|
||||
e-mail principal — via uma RPC dev-only que cruza tenants+profiles+subscriptions
|
||||
(SECURITY DEFINER). Realce em verde + selo "Novo" pra cliente criado nas últimas
|
||||
24h (rowClass baseado em created_at). Reaproveite essa RPC pra mostrar o e-mail
|
||||
principal também nas listagens de assinaturas e tenants.
|
||||
- Notificação aos devs quando nasce/muda uma assinatura (incl. trial): trigger em
|
||||
subscriptions chamando a função notify_all_devs com deeplink. ⚠️ PEGADINHA #4:
|
||||
se o sino de notificações é um singleton com flag "initialized", garanta que ele
|
||||
RE-BUSCA ao trocar de usuário (logout+login), senão fica stale e ainda vaza
|
||||
notificações entre usuários. A notificação só aparece pós-provisionamento e no
|
||||
sino do DEV (não do novo usuário).
|
||||
- "Esqueci meu e-mail": tela onde a pessoa informa o IDENTIFICADOR do sindicato
|
||||
(slug, que ela escolheu e foi avisada ser definitivo) → o servidor acha o e-mail
|
||||
do dono → mostra só uma DICA MASCARADA (jo****@gm****.com) → envia magic link
|
||||
(signInWithOtp, que usa o mesmo pipeline de e-mail do GoTrue, sem depender de
|
||||
Resend) → a pessoa clica e entra. O e-mail real NUNCA volta pro cliente.
|
||||
- root_redirect: coluna em config + RPC pública + guard, pra escolher pra onde o
|
||||
visitante não logado vai na raiz "/" (landing ou login).
|
||||
- Lista de bloqueio (blacklist) de e-mails e slugs, gerida em Configurações:
|
||||
tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||
trigger BEFORE INSERT em auth.users (não só no front); suporte a domínio inteiro
|
||||
com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||
|
||||
## MÉTODO DE TRABALHO
|
||||
- Tudo numa branch nova. Commits pequenos por assunto, mensagem clara.
|
||||
- Cada migration: aplique no banco local e TESTE em transação com ROLLBACK (crie
|
||||
auth.users fake + impersone via set_config('request.jwt.claims',...)) antes de
|
||||
seguir. RPCs idempotentes.
|
||||
- Rode o build do frontend a cada bloco pra pegar erro cedo.
|
||||
- NUMERE as migrations com cuidado pra não colidir versão (quebra o db push).
|
||||
- Me mostre o mapa da Fase 0 e as decisões da Fase 0.5 ANTES de codar.
|
||||
|
||||
## DEPLOY (no fim)
|
||||
Migrations no hosted (db push) → dashboard Auth "Confirm email" ON + Site/Redirect
|
||||
URLs corretas → deploy das edge functions + secret do provedor de e-mail → rebuild
|
||||
do frontend → smoke test do fluxo: /lp → grátis → confirma e-mail → entra
|
||||
provisionado → limite bloqueia → sino do dev → esqueci-email.
|
||||
|
||||
---
|
||||
Esse prompt é "diretor": ele força a IA a mapear o teu outro sistema primeiro (porque as tabelas/nomes vão diferir) e
|
||||
te apresentar decisões antes de codar — do jeito que fizemos aqui. As 4 pegadinhas marcadas com ⚠️ são as que mais
|
||||
custaram tempo; com elas escritas, a IA evita de cara.
|
||||
|
||||
Quer que eu gere também uma versão curta (1 parágrafo) pra um primeiro disparo, ou uma variante específica caso o
|
||||
outro sistema seja RLS puro (sem schema-per-tenant)? Aí eu ajusto os trechos de provisionamento/enforcement.
|
||||
Reference in New Issue
Block a user