diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index 0fbfbc4..6d526e9 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -1693,3 +1693,6 @@ Touched: Migracao Schema-per-Tenant ## [2026-06-13 17:14] session | schema-per-tenant F0-F6.4 + wiring + rollback (F6.3 nao aplicada) Touched: Migracao Schema-per-Tenant + +## [2026-06-13 18:30] session | Freemium/PLG F0 descoberta + 4 decisões +Touched: Freemium PLG diff --git a/Obsidian/Brain/wiki/Freemium PLG.md b/Obsidian/Brain/wiki/Freemium PLG.md new file mode 100644 index 0000000..aa0ebf2 --- /dev/null +++ b/Obsidian/Brain/wiki/Freemium PLG.md @@ -0,0 +1,41 @@ +# Freemium / PLG + +Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica. + +## Descoberta (Fase 0) — o que já existia + +O sistema já estava ~70-85% pronto: +- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free` → `clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF). +- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature` → `/upgrade` (`guards.js:814`), badge PRO no menu. +- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant` → `clone_tenant_template`. Setup Wizard. +- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago). +- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado. + +**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras. + +## Decisões (Fase 0.5) + +1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline. +2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas. +3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas. +4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect. + +## Pegadinhas (do blueprint, ⚠️ caras no irmão) + +- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação. +- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado. +- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro). +- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login). + +## Divergência de infra + +Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend. + +## Fases + +- **F1** — Fundação: ajustar limits free (therapist_free max_patients) + trigger de enforcement + toast PLAN_LIMIT_REACHED + botão Upgrade PRO. +- **F2** — Self-service: `enable_confirmations`, RPCs idempotentes, signup rewrite (nome+slug+metadata), `/onboarding`, welcome email, plano free visível na vitrine. +- **F3** — Extras (4). +- **F4** — Deploy (hosted, dirigido pelo Leonardo). + +Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front. diff --git a/Obsidian/Brain/wiki/index.md b/Obsidian/Brain/wiki/index.md index 606a667..f85337f 100644 --- a/Obsidian/Brain/wiki/index.md +++ b/Obsidian/Brain/wiki/index.md @@ -31,3 +31,4 @@ _(synthesized answers to questions you've asked, filed back as pages)_ *This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.* - [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4) +- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg diff --git a/database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql b/database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql new file mode 100644 index 0000000..4f9e63f --- /dev/null +++ b/database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql @@ -0,0 +1,147 @@ +-- ============================================================================= +-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant +-- +-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de +-- wiring trg_attach_business_triggers é owned por supabase_admin). +-- +-- Trigger genérico BEFORE INSERT em .patients que: +-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas); +-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id; +-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL); +-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número +-- no painel passa a valer sem deploy); +-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável +-- 'PLAN_LIMIT_REACHED|patients|' quando já atingiu. +-- +-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia. +-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public +-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3. +-- ============================================================================= + +BEGIN; + +-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------ +CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid) +RETURNS uuid +LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ + SELECT COALESCE( + -- clínica: subscription chaveada por tenant_id + (SELECT vas.plan_id + FROM public.v_tenant_active_subscription vas + WHERE vas.tenant_id = p_tenant_id), + -- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL + (SELECT s.plan_id + FROM public.subscriptions s + JOIN public.tenant_members tm + ON tm.user_id = s.user_id + AND tm.tenant_id = p_tenant_id + AND tm.status = 'active' + WHERE s.status = 'active' + AND s.tenant_id IS NULL + AND (s.current_period_end IS NULL OR s.current_period_end > now()) + ORDER BY s.created_at DESC + LIMIT 1) + ); +$$; +ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin; + +-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) ------- +-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free +-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um. +CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text) +RETURNS int +LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ + SELECT min((pf.limits->>p_limit_key)::int) + FROM public.plan_features pf + WHERE pf.plan_id = p_plan_id + AND pf.enabled + AND pf.limits ? p_limit_key + AND (pf.limits->>p_limit_key) ~ '^[0-9]+$'; +$$; +ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin; + +-- 3) Trigger function de enforcement ----------------------------------------- +CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE + v_tenant uuid; + v_plan uuid; + v_limit int; + v_count int; +BEGIN + SELECT tenant_id INTO v_tenant + FROM public.tenant_schemas + WHERE schema_name = TG_TABLE_SCHEMA; + IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora + + v_plan := public.tenant_active_plan_id(v_tenant); + IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia + + v_limit := public.plan_feature_limit(v_plan, 'max_patients'); + IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado + + EXECUTE format( + 'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L', + TG_TABLE_SCHEMA, 'Arquivado' + ) INTO v_count; + + IF v_count >= v_limit THEN + RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; +END $$; +ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin; + +-- 4) Attach helper (pendura o trigger no patients de um schema) --------------- +CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text) +RETURNS int +LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + IF p_schema NOT LIKE 'tenant\_%' THEN + RAISE EXCEPTION 'schema inválido %', p_schema; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = p_schema AND table_name = 'patients' + ) THEN + EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema); + EXECUTE format( + 'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients ' + 'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema); + RETURN 1; + END IF; + RETURN 0; +END $$; +ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin; +GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role; + +-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ---------------- +CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM public.attach_agnostic_triggers(NEW.schema_name); + PERFORM public.attach_schema_aware_triggers(NEW.schema_name); + PERFORM public.attach_notif_triggers(NEW.schema_name); + PERFORM public.attach_plan_limit_triggers(NEW.schema_name); + RETURN NULL; +END $$; +ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin; + +-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora ------------- +DO $$ +DECLARE r record; n int := 0; +BEGIN + FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP + n := n + public.attach_plan_limit_triggers(r.schema_name); + END LOOP; + RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n; +END $$; + +COMMIT; diff --git a/database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql b/database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql new file mode 100644 index 0000000..2634e05 --- /dev/null +++ b/database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- Freemium F1 — limite de pacientes do plano therapist_free +-- +-- clinic_free já traz max_patients=30 (em plan_features.limits da feature +-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes. +-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free +-- já possui, enabled). +-- +-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid +-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb). +-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits. +-- ============================================================================= + +BEGIN; + +UPDATE public.plan_features pf +SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20) +WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free') + AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage'); + +-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada). +DO $$ +DECLARE v int; +BEGIN + SELECT (pf.limits->>'max_patients')::int INTO v + FROM public.plan_features pf + WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free') + AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage'); + IF v IS DISTINCT FROM 20 THEN + RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v; + END IF; +END $$; + +COMMIT; diff --git a/novo-rumo.txt b/novo-rumo.txt index bcf7242..2f45a24 100644 --- a/novo-rumo.txt +++ b/novo-rumo.txt @@ -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_ - 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(''). - 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 = ''). - - 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('') → db().from('') 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_", "tenant_", ...] - 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_.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_', 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('') → db().from(...). Importe useTenantDb. - 2. Caça por .eq('tenant_id': remova nos from(''), mantenha nos from(''). - 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_ pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl > - backups/pre-loteN/public.sql - docker exec supabase_db_ pg_dump -U postgres -d postgres --schema=tenant_ --no-owner --no-acl > - backups/pre-loteN/tenant_.sql - - Pra recarregar cache do PostgREST após mudanças: - docker exec supabase_db_ psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'" - - Se mudou config.toml (schemas expostos), restart obrigatório: - docker restart supabase_rest_ - - 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||'. 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. \ No newline at end of file