7 Commits

Author SHA1 Message Date
Leonardo 5a87c29dd0 freemium F2: registra nucleo no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:05:55 -03:00
Leonardo a2f3b9fae4 freemium F2: signup self-service com confirmacao + /onboarding
- Signup.vue reescrito: coleta tipo de conta + nome + nome do negocio + slug
  (disponibilidade ao vivo via slug_disponivel) + email/senha; grava tudo no
  raw_user_meta_data do signUp; PEGADINHA #2: signOut scope:local se nao veio
  sessao + tela "confirme seu e-mail". Removido provisionamento/intent inline.
- OnboardingPage.vue (PEGADINHA #3): 1o login chama auto_provision_free_tenant
  + processar_pos_signup; resolve estados provisionando/slug-colidiu/erro;
  redireciona pro painel conforme kind.
- guard: logado-sem-tenant (nao saas_admin) -> /onboarding em vez de /login
- rota /onboarding (meta.public; a pagina exige sessao)

NOTA: supabase/config.toml e gitignored — enable_confirmations=true foi setado
local (ativa no proximo restart do stack). No hosted, ligar em Auth>Email>Confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:00:22 -03:00
Leonardo 1594dc9426 freemium F2: RPCs idempotentes do self-service
- slug_disponivel(p_slug) -> {ok, motivo} (formato + reservados + uso),
  chamavel por anon (signup acontece antes do login)
- auto_provision_free_tenant(p_slug_override): le raw_user_meta_data, cria
  tenant (slug escolhido OU auto), vira master, clona schema, cria subscription
  gratuita ativa (XOR clinic->tenant_id / therapist->user_id). Idempotente.
- processar_pos_signup(): cria intent SO pro caminho pago, na tabela real por
  target (a view subscription_intents nao propaga user_id). Idempotente.
- testado em ROLLBACK: clinica + terapeuta, provision+idempotencia+intents OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:20:52 -03:00
Leonardo 31c4f08451 fix(audit): log_audit_change quebrava INSERT em tabelas globais
Regressao do schema-per-tenant: o trigger de auditoria deriva tenant_id via
tenant_id_for_schema(TG_TABLE_SCHEMA), que retorna NULL para public.* (ex.:
tenant_members) -> violava audit_logs.tenant_id NOT NULL -> QUALQUER novo
membership (provisionamento, aceite de convite) falhava.

Fix: quando o schema nao resolve tenant, cai no tenant_id da propria linha;
se ainda NULL, nao audita mas nunca quebra a operacao.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:20:51 -03:00
Leonardo 12d5c3b6dc freemium F1: registra conclusao no wiki/log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:07:00 -03:00
Leonardo a979bdf1de freemium F1: frontend do enforcement (toast + botao Upgrade PRO)
- utils/planLimit.js: parsePlanLimitError + maybeShowPlanLimitToast (traduz
  PLAN_LIMIT_REACHED em toast amigavel com CTA via grupo system-alerts)
- AppTopbar: botao "Upgrade PRO" quando plano ativo e gratuito (reusa
  resolveActiveSubscriptionContext; plan_key nos selects)
- ligado nos 3 pontos de criacao de paciente: PatientsCadastroPage,
  CadastrosRecebidos (intake), ComponentCadastroRapido (quick-create)
- build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:05:28 -03:00
Leonardo a73b82fa86 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>
2026-06-13 18:05:19 -03:00
17 changed files with 1148 additions and 432 deletions
+9
View File
@@ -1693,3 +1693,12 @@ 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
## [2026-06-13 19:45] session | Freemium F1 done (enforcement pacientes + Upgrade PRO)
Touched: Freemium PLG
## [2026-06-13 21:40] session | Freemium F2 nucleo (RPCs + signup + onboarding + audit fix)
Touched: Freemium PLG
+41
View File
@@ -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|<n>'`. 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** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
- **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.
+1
View File
@@ -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
@@ -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 <schema>.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|<limite>' 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;
@@ -0,0 +1,238 @@
-- =============================================================================
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
--
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
-- subscriptions/profiles + roda clone_tenant_template).
--
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
-- no 1º login pós-confirmação:
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
--
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
-- =============================================================================
BEGIN;
-- 1) slug_disponivel ---------------------------------------------------------
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
-- tenant além do fato "slug em uso".
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v text := lower(trim(coalesce(p_slug, '')));
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
BEGIN
IF length(v) < 3 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
END IF;
IF length(v) > 48 THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
END IF;
-- começa com letra, só [a-z0-9_]
IF v !~ '^[a-z][a-z0-9_]*$' THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
END IF;
IF v = ANY(v_reservados) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
END IF;
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
END IF;
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
END $$;
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
-- 2) auto_provision_free_tenant ----------------------------------------------
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_kind text;
v_acct text;
v_name text;
v_slug text;
v_display text;
v_tenant_id uuid;
v_plan_key text;
v_plan_id uuid;
v_target text;
v_existing uuid;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
-- idempotência: já tem tenant ativo?
SELECT tm.tenant_id INTO v_existing
FROM public.tenant_members tm
WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
v_kind := 'therapist';
END IF;
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
v_display := nullif(trim(v_meta->>'display_name'), '');
v_name := coalesce(
nullif(trim(v_meta->>'tenant_name'), ''),
v_display,
split_part(coalesce(v_email, 'conta'), '@', 1),
'Conta');
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
IF v_slug = '' THEN
v_slug := NULL;
ELSE
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
END IF;
END IF;
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
INSERT INTO public.tenants (name, kind, slug, created_at)
VALUES (v_name, v_kind, v_slug, now())
RETURNING id, slug INTO v_tenant_id, v_slug;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
UPDATE public.profiles
SET account_type = v_acct,
full_name = COALESCE(full_name, v_display)
WHERE id = v_uid;
-- provisiona o schema físico + seed
PERFORM public.clone_tenant_template(v_tenant_id);
PERFORM public.seed_determined_commitments(v_tenant_id);
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
tenant_id, user_id, started_at, activated_at, current_period_start)
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
now(), now(), now());
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
END $$;
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
-- 3) processar_pos_signup ----------------------------------------------------
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $$
DECLARE
v_uid uuid := auth.uid();
v_meta jsonb;
v_email text;
v_plan_key text;
v_interval text;
v_plan record;
v_tenant uuid;
v_amount int;
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
END IF;
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
FROM auth.users au WHERE au.id = v_uid;
v_meta := COALESCE(v_meta, '{}'::jsonb);
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
-- sem plano escolhido OU plano gratuito → nada a fazer
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
RETURN jsonb_build_object('status', 'no_intent');
END IF;
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
IF NOT FOUND THEN
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
END IF;
-- idempotência: já existe intent 'new' desse usuário+plano?
IF EXISTS (
SELECT 1 FROM public.subscription_intents
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
) THEN
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
END IF;
SELECT tm.tenant_id INTO v_tenant
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
ORDER BY tm.created_at ASC LIMIT 1;
v_amount := CASE WHEN v_interval = 'year'
THEN COALESCE(v_plan.price_cents, 0) * 12
ELSE COALESCE(v_plan.price_cents, 0) END;
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
-- trigger que não propaga user_id pra _tenant; o serviço do front também
-- escreve nas tabelas reais por target).
IF lower(v_plan.target) = 'clinic' THEN
INSERT INTO public.subscription_intents_tenant
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
ELSE
INSERT INTO public.subscription_intents_personal
(user_id, created_by_user_id, email, plan_id, plan_key,
interval, amount_cents, currency, status, source)
VALUES
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
v_interval, v_amount, 'BRL', 'new', 'signup');
END IF;
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
END $$;
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
COMMIT;
@@ -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;
@@ -0,0 +1,53 @@
-- =============================================================================
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
-- GLOBAIS auditadas.
--
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
--
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
-- audita — mas NUNCA quebra a operação de negócio.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
AS $function$
DECLARE
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
BEGIN
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
IF v_tenant_id IS NULL THEN
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
END IF;
-- sem tenant resolvível → não audita, mas não quebra a operação
IF v_tenant_id IS NULL THEN
RETURN COALESCE(NEW, OLD);
END IF;
IF TG_OP = 'DELETE' THEN
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
ELSE
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
IF v_changed IS NULL THEN RETURN NEW; END IF;
IF v_changed <@ v_noise THEN RETURN NEW; END IF;
END IF;
INSERT INTO public.audit_logs (tenant_id, user_id, entity_type, entity_id, action, old_values, new_values, changed_fields)
VALUES (v_tenant_id, auth.uid(), TG_TABLE_NAME, v_entity_id, lower(TG_OP), v_old, v_new, v_changed);
RETURN COALESCE(NEW, OLD);
END $function$;
+150 -338
View File
@@ -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.
+9 -6
View File
@@ -21,6 +21,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard';
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
import { useToast } from 'primevue/usetoast';
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
import InputMask from 'primevue/inputmask';
import Message from 'primevue/message';
@@ -269,12 +270,14 @@ async function submit(mode = 'only') {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
});
if (!maybeShowPlanLimitToast(toast, err, route.fullPath)) {
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
});
}
console.error('[ComponentCadastroRapido] insert error:', err);
} finally {
@@ -68,6 +68,7 @@ import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'
import { logError } from '@/support/supportLogger'
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
import { maybeShowPlanLimitToast } from '@/utils/planLimit'
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
@@ -680,7 +681,9 @@ async function onSubmit () {
await openPanel(0)
} catch (e) {
logError('patients.cadastro', 'save falhou', e)
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
if (!maybeShowPlanLimitToast(toast, e, route.fullPath)) {
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
}
} finally { saving.value=false }
}
@@ -21,6 +21,7 @@ import { useTenantStore } from '@/stores/tenantStore';
// extraídos pro repository pra remover duplicação.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { logError } from '@/support/supportLogger';
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
@@ -417,7 +418,9 @@ async function convertToPatient() {
await fetchIntakes();
} catch (err) {
logError('patients.recebidos', 'converter paciente falhou', err);
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
if (!maybeShowPlanLimitToast(toast, err, '/admin/pacientes/recebidos')) {
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
}
} finally {
converting.value = false;
}
+52 -2
View File
@@ -47,6 +47,7 @@ import { applyThemeEngine } from '@/theme/theme.options';
import { fetchAllNotices } from '@/features/notices/noticeService';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
import { buildUpgradeUrl } from '@/utils/upgradeContext';
const toast = useToast();
const entitlementsStore = useEntitlementsStore();
@@ -198,6 +199,30 @@ const showPlanDevMenu = computed(() => {
return canSee('settings.view') && enablePlanToggle.value;
});
/* ----------------------------
Upgrade PRO — botão user-facing quando o plano ativo é gratuito.
Reusa resolveActiveSubscriptionContext() (clínica via tenant_id,
pessoal via user_id) e expõe só plan_key pra decidir o badge.
----------------------------- */
const activePlanKey = ref(null);
const isFreePlan = computed(() => {
const k = String(activePlanKey.value || '').toLowerCase();
return k.endsWith('_free') || k === 'free';
});
async function loadPlanBadge() {
try {
const { sub } = await resolveActiveSubscriptionContext();
activePlanKey.value = sub?.plan_key ?? null;
} catch {
activePlanKey.value = null; // ausência do badge nunca pode quebrar a topbar
}
}
function goUpgrade() {
router.push(buildUpgradeUrl({ redirectTo: route.fullPath }));
}
const ctxMenu = ref();
const ctxMenuModel = computed(() =>
ctxItems.value.length
@@ -229,7 +254,7 @@ async function getMyUserId() {
async function getActiveTherapistSubscription() {
const uid = await getMyUserId();
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, plan_key, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
if (error) throw error;
@@ -259,7 +284,7 @@ async function getActiveClinicSubscription() {
const tid = tenantId.value;
if (!tid) return null;
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, plan_key, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
return data || null;
@@ -556,7 +581,16 @@ onMounted(async () => {
await loadAndApplyUserSettings();
await loadSessionIdentity();
await bootstrapEntitlements();
loadPlanBadge();
});
// recarrega o badge de plano só ao trocar de tenant ou de contexto (clínica vs
// pessoal) — não a cada navegação, pra evitar queries desnecessárias.
const isClinicArea = computed(() => {
const p = route.path || '';
return p.startsWith('/admin') || p.startsWith('/supervisor');
});
watch([tenantId, isClinicArea], () => { loadPlanBadge(); });
</script>
<template>
@@ -602,6 +636,9 @@ onMounted(async () => {
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Upgrade PRO quando o plano ativo é gratuito -->
<Button v-if="isFreePlan" label="Upgrade PRO" icon="pi pi-star-fill" size="small" class="rail-topbar__upgrade-btn" @click="goUpgrade" />
<!-- Plan Dev Button -->
<Button v-if="showPlanDevMenu" ref="planBtn" outlined :loading="planMenuLoading || trocandoPlano" :disabled="planMenuLoading || trocandoPlano" @click="openPlanMenu" class="rail-topbar__btn">
<i class="pi pi-sliders-h" />
@@ -724,6 +761,19 @@ onMounted(async () => {
background: var(--surface-ground);
color: var(--primary-color);
}
/* ── Botão Upgrade PRO (plano gratuito) ──────────────────── */
.rail-topbar__upgrade-btn {
background: linear-gradient(135deg, #f59e0b, #d97706);
border: none;
color: #fff;
font-weight: 600;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(217, 119, 6, 0.35);
}
.rail-topbar__upgrade-btn:hover {
filter: brightness(1.05);
}
.config-panel {
z-index: 200;
}
+8
View File
@@ -632,6 +632,14 @@ export function applyGuards(router) {
const firstActive = preferred || mem.find((m) => m && m.status === 'active' && m.tenant_id);
if (!firstActive) {
// ✅ Freemium F2: logado mas SEM nenhum tenant ativo → /onboarding,
// que provisiona o plano gratuito e resolve estados (slug colidiu,
// pago aguardando, erro). saas_admin não passa por aqui.
if (globalRole !== 'saas_admin' && to.path !== '/onboarding') {
_perfEnd();
return { path: '/onboarding' };
}
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
if (isTenantArea) {
sessionStorage.setItem('redirect_after_login', to.fullPath);
+9
View File
@@ -58,6 +58,15 @@ export default {
name: 'shared.document',
component: () => import('@/views/pages/public/SharedDocumentPage.vue'),
meta: { public: true }
},
// ✅ Freemium F2: onboarding pós-confirmação (provisiona o tenant gratuito).
// meta.public p/ não passar pela lógica de tenant do guard; a própria
// página exige sessão (redireciona pra /auth/login se não houver).
{
path: '/onboarding',
name: 'onboarding',
component: () => import('@/views/pages/auth/OnboardingPage.vue'),
meta: { public: true }
}
]
};
+57
View File
@@ -0,0 +1,57 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/utils/planLimit.js
|
| Traduz o erro de enforcement de limite de plano vindo do banco
| (RAISE 'PLAN_LIMIT_REACHED|<recurso>|<limite>') num toast amigável com
| CTA de upgrade, reusando o grupo de toast 'system-alerts' do AppLayout
| (que já renderiza um botão de ação a partir de data.deeplink).
|--------------------------------------------------------------------------
*/
import { buildUpgradeUrl } from '@/utils/upgradeContext';
const RESOURCE_LABELS = {
patients: 'pacientes'
};
/**
* Extrai {resource, limit} de um erro de limite de plano. Retorna null se o
* erro não for um PLAN_LIMIT_REACHED (deixa o caller tratar genericamente).
*/
export function parsePlanLimitError(err) {
const msg = err?.message || err?.error_description || err?.details || '';
const m = /PLAN_LIMIT_REACHED\|([a-z_]+)\|(\d+)/i.exec(String(msg));
if (!m) return null;
return { resource: m[1], limit: Number(m[2]) };
}
/**
* Se `err` for um erro de limite de plano, mostra o toast amigável com CTA e
* retorna true (o caller deve então parar — não mostrar o erro genérico).
* Caso contrário retorna false.
*
* @param {object} toast instância do PrimeVue useToast()
* @param {Error} err erro capturado
* @param {string|null} redirectTo rota pra voltar após o upgrade (ex: route.fullPath)
*/
export function maybeShowPlanLimitToast(toast, err, redirectTo = null) {
const parsed = parsePlanLimitError(err);
if (!parsed) return false;
const label = RESOURCE_LABELS[parsed.resource] || parsed.resource;
toast?.add?.({
group: 'system-alerts',
severity: 'warn',
summary: 'Limite do plano gratuito',
detail: `Você atingiu o limite de ${parsed.limit} ${label} do plano gratuito. Faça upgrade para adicionar mais.`,
life: 8000,
data: {
deeplink: buildUpgradeUrl({ redirectTo }),
actionLabel: 'Fazer upgrade'
}
});
return true;
}
+168
View File
@@ -0,0 +1,168 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI OnboardingPage (Freemium F2)
|--------------------------------------------------------------------------
| Tela do 1º login pós-confirmação. Provisiona o tenant gratuito via
| auto_provision_free_tenant ( o raw_user_meta_data) e resolve estados:
| provisionando, slug colidiu (deixa reescolher), erro (retry). Pega o
| caminho pago via processar_pos_signup (best-effort). Pegadinha #3: um
| logado-sem-tenant nunca pode cair num painel quebrado.
|--------------------------------------------------------------------------
-->
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Message from 'primevue/message';
import ProgressSpinner from 'primevue/progressspinner';
const router = useRouter();
const tenant = useTenantStore();
const state = ref('provisioning'); // provisioning | slug_collision | error | done
const errorMsg = ref('');
// slug colidiu — reescolher
const slug = ref('');
const slugStatus = ref('idle'); // idle|checking|ok|curto|longo|invalido|reservado|em_uso|bloqueado|erro
let slugTimer = null;
const slugOk = computed(() => slugStatus.value === 'ok');
const slugMessage = computed(() => ({
checking: 'Verificando…',
ok: 'Disponível ✓',
curto: 'Mínimo de 3 caracteres.',
longo: 'Máximo de 48 caracteres.',
invalido: 'Use letras minúsculas, números e _ (começando com letra).',
reservado: 'Esse identificador é reservado.',
em_uso: 'Esse identificador já está em uso.',
bloqueado: 'Esse identificador não está disponível.',
erro: 'Não consegui verificar agora.'
}[slugStatus.value] || ''));
function slugify(s) {
let b = String(s || '').toLowerCase().trim();
b = b.normalize('NFD').replace(/[̀-ͯ]/g, '');
b = b.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
b = b.slice(0, 48);
if (!b || !/^[a-z]/.test(b)) b = ('t_' + b).slice(0, 48);
return b;
}
function onSlugInput() {
slug.value = slugify(slug.value);
if (slugTimer) clearTimeout(slugTimer);
if (!slug.value || slug.value.length < 3) { slugStatus.value = slug.value ? 'curto' : 'idle'; return; }
slugStatus.value = 'checking';
slugTimer = setTimeout(async () => {
try {
const { data, error } = await supabase.rpc('slug_disponivel', { p_slug: slug.value });
if (error) throw error;
slugStatus.value = data?.ok ? 'ok' : (data?.motivo || 'invalido');
} catch {
slugStatus.value = 'erro';
}
}, 400);
}
function homePathForKind(kind) {
return kind === 'therapist' ? '/therapist' : '/admin';
}
async function finishAndRedirect(kind) {
// recarrega o tenant store (pega a nova membership) e entra no painel
tenant.reset();
await tenant.loadSessionAndTenant();
state.value = 'done';
router.replace(homePathForKind(kind));
}
async function provision(slugOverride = null) {
state.value = 'provisioning';
errorMsg.value = '';
try {
const { data, error } = await supabase.rpc('auto_provision_free_tenant', {
p_slug_override: slugOverride
});
if (error) throw error;
// caminho pago (intent) — best-effort, não bloqueia
try { await supabase.rpc('processar_pos_signup'); } catch (e) { console.warn('[onboarding] processar_pos_signup:', e?.message || e); }
await finishAndRedirect(data?.kind || 'therapist');
} catch (err) {
const msg = String(err?.message || '');
if (/SLUG_TAKEN/i.test(msg)) {
state.value = 'slug_collision';
return;
}
// sem sessão → manda pro login
if (/sem sess|28000|JWT|not authenticated/i.test(msg)) {
router.replace('/auth/login');
return;
}
errorMsg.value = msg || 'Não consegui preparar seu ambiente.';
state.value = 'error';
}
}
async function retryWithSlug() {
if (!slugOk.value) return;
await provision(slug.value);
}
onMounted(async () => {
// exige sessão
const { data } = await supabase.auth.getSession();
if (!data?.session?.user) {
router.replace('/auth/login');
return;
}
await provision();
});
</script>
<template>
<div class="min-h-screen bg-[var(--surface-ground)] text-[var(--text-color)] flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm p-8 text-center">
<!-- provisionando -->
<template v-if="state === 'provisioning' || state === 'done'">
<ProgressSpinner style="width: 48px; height: 48px" strokeWidth="4" />
<div class="text-xl font-semibold mt-5">Preparando seu ambiente</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-2">Criando seu espaço e ativando o plano gratuito. Leva um instante.</div>
</template>
<!-- slug colidiu -->
<template v-else-if="state === 'slug_collision'">
<div class="h-12 w-12 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] mx-auto grid place-items-center">
<i class="pi pi-pencil text-xl opacity-80" />
</div>
<div class="text-xl font-semibold mt-4">Escolha outro identificador</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-2">O identificador que você escolheu está em uso. Escolha outro ele é definitivo.</div>
<div class="mt-5 text-left">
<InputText v-model="slug" class="w-full" placeholder="meu_consultorio" @input="onSlugInput" @blur="onSlugInput" />
<div v-if="slug" class="mt-1 text-xs" :class="slugOk ? 'text-emerald-600' : (slugStatus === 'checking' ? 'text-[var(--text-color-secondary)]' : 'text-orange-600')">
{{ slugMessage }}
</div>
</div>
<Button label="Continuar" icon="pi pi-arrow-right" iconPos="right" class="w-full mt-4" :disabled="!slugOk" @click="retryWithSlug" />
</template>
<!-- erro -->
<template v-else>
<div class="h-12 w-12 rounded-2xl border border-red-200 bg-red-50 mx-auto grid place-items-center">
<i class="pi pi-exclamation-triangle text-xl text-red-500" />
</div>
<div class="text-xl font-semibold mt-4">Algo deu errado</div>
<Message severity="error" class="mt-3 text-left">{{ errorMsg }}</Message>
<Button label="Tentar de novo" icon="pi pi-refresh" class="w-full mt-4" @click="() => provision()" />
<Button label="Sair" text class="w-full mt-2" @click="() => router.replace('/auth/login')" />
</template>
</div>
</div>
</template>
+164 -84
View File
@@ -24,6 +24,7 @@ import Password from 'primevue/password';
import Chip from 'primevue/chip';
import Message from 'primevue/message';
import ProgressSpinner from 'primevue/progressspinner';
import SelectButton from 'primevue/selectbutton';
const route = useRoute();
const router = useRouter();
@@ -34,12 +35,88 @@ const toast = useToast();
// ============================
const email = ref('');
const password = ref('');
const displayName = ref('');
const businessName = ref('');
const slug = ref('');
const slugTouched = ref(false);
const loading = ref(false);
// validação simples (sem "viajar")
// tela "confirme seu e-mail" (quando a confirmação está ligada e o signUp não
// retorna sessão)
const signedUp = ref(false);
const signedUpEmail = ref('');
// tipo de conta — deriva do plano da query, default terapeuta
const kindOptions = [
{ label: 'Sou terapeuta', value: 'therapist' },
{ label: 'Somos uma clínica', value: 'clinic_full' }
];
const accountKind = ref('therapist');
// ============================
// Slug do tenant (= nome do schema físico, IMUTÁVEL)
// ============================
// espelha public.generate_tenant_slug pra a sugestão local; a verdade é a RPC.
function slugify(s) {
let b = String(s || '').toLowerCase().trim();
b = b.normalize('NFD').replace(/[̀-ͯ]/g, ''); // tira acentos
b = b.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
b = b.slice(0, 48);
if (!b || !/^[a-z]/.test(b)) b = ('t_' + b).slice(0, 48);
return b;
}
const slugStatus = ref('idle'); // idle|checking|ok|curto|longo|invalido|reservado|em_uso|bloqueado|erro
let slugTimer = null;
function onSlugInput() {
slugTouched.value = true;
slug.value = slugify(slug.value);
}
async function checkSlug() {
try {
const { data, error } = await supabase.rpc('slug_disponivel', { p_slug: slug.value });
if (error) throw error;
slugStatus.value = data?.ok ? 'ok' : (data?.motivo || 'invalido');
} catch {
slugStatus.value = 'erro';
}
}
watch(businessName, (v) => {
if (!slugTouched.value) slug.value = slugify(v);
});
watch(slug, (v) => {
if (slugTimer) clearTimeout(slugTimer);
if (!v) { slugStatus.value = 'idle'; return; }
if (v.length < 3) { slugStatus.value = 'curto'; return; }
slugStatus.value = 'checking';
slugTimer = setTimeout(checkSlug, 400);
});
const slugOk = computed(() => slugStatus.value === 'ok');
const slugMessage = computed(() => ({
checking: 'Verificando disponibilidade…',
ok: 'Disponível ✓',
curto: 'Mínimo de 3 caracteres.',
longo: 'Máximo de 48 caracteres.',
invalido: 'Use letras minúsculas, números e _ (começando com letra).',
reservado: 'Esse identificador é reservado.',
em_uso: 'Esse identificador já está em uso.',
bloqueado: 'Esse identificador não está disponível.',
erro: 'Não consegui verificar agora.'
}[slugStatus.value] || ''));
// validação
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()));
const passwordOk = computed(() => String(password.value || '').length >= 6);
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value);
const nameOk = computed(() => String(displayName.value || '').trim().length >= 2);
const businessOk = computed(() => String(businessName.value || '').trim().length >= 2);
const canSubmit = computed(() =>
!loading.value && emailOk.value && passwordOk.value && nameOk.value && businessOk.value && slugOk.value
);
// ============================
// Query (plan / interval)
@@ -148,46 +225,11 @@ watch(
() => loadSelectedPlanRow()
);
// ============================
// subscription_intent (MODELO B: tenant)
// ============================
async function getActiveTenantIdForUser(userId) {
const { data, error } = await supabase.from('tenant_members').select('tenant_id').eq('user_id', userId).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
return data?.tenant_id || null;
}
async function createSubscriptionIntentAfterSignup(userId, preferredTenantId = null) {
if (!hasPlanQuery.value) return;
if (!selectedPlanRow.value) return;
if (amountCents.value == null) return;
const tenantId = preferredTenantId || (await getActiveTenantIdForUser(userId));
if (!tenantId) throw new Error('Não encontrei tenant ativo para este usuário.');
const payload = {
tenant_id: tenantId,
created_by_user_id: userId,
// opcional (se sua tabela ainda tem user_id)
user_id: userId,
email:
String(email.value || '')
.trim()
.toLowerCase() || null,
plan_key: selectedPlanRow.value.plan_key,
interval: intervalNormalized.value,
amount_cents: amountCents.value,
currency: currency.value || 'BRL',
status: 'new',
source: 'landing'
};
const { error } = await supabase.from('subscription_intents').insert(payload);
if (error) throw error;
}
// NOTA F2: provisionamento de tenant + criação de intent NÃO acontecem mais
// aqui (com confirmação de e-mail ligada o signUp não tem sessão e tudo que
// depende de auth.uid() quebraria em silêncio). A escolha vai no metadata do
// signUp e é processada no 1º login pós-confirmação por auto_provision_free_tenant
// / processar_pos_signup (ver session.js / OnboardingPage).
// ============================
// Nav
@@ -216,53 +258,40 @@ async function onSignup() {
.trim()
.toLowerCase();
// grava a escolha no raw_user_meta_data — processada no 1º login
// (auto_provision_free_tenant / processar_pos_signup)
const { data, error } = await supabase.auth.signUp({
email: cleanEmail,
password: password.value
password: password.value,
options: {
data: {
account_kind: accountKind.value,
tenant_name: String(businessName.value || '').trim(),
tenant_slug: String(slug.value || '').trim(),
display_name: String(displayName.value || '').trim(),
plan_key: planFromQuery.value || null,
billing_interval: intervalNormalized.value || null
}
}
});
if (error) throw error;
const userId = data?.user?.id || null;
// ✅ Modelo B: garante tenant pessoal (não aborta se falhar)
let tenantId = null;
if (userId) {
try {
const resTenant = await supabase.rpc('ensure_personal_tenant');
tenantId = resTenant?.data || null;
} catch (e) {
console.warn('[Signup] ensure_personal_tenant falhou:', e);
}
// ✅ intent (não quebra signup se falhar)
try {
await createSubscriptionIntentAfterSignup(userId, tenantId);
} catch (e) {
console.error('[Signup] subscription_intent failed:', e);
toast.add({
severity: 'warn',
summary: 'Conta criada',
detail: 'Não consegui registrar a intenção do plano. Você pode seguir normalmente.',
life: 4500
});
}
// ⚠️ PEGADINHA #2: se NÃO veio sessão (confirmação pendente), encerra
// qualquer sessão local e mostra "confirme seu e-mail". Sem isso, uma
// sessão anterior (ex: dev testando) vazaria e o push pro painel mandaria
// o usuário pro ambiente da sessão antiga.
if (!data?.session) {
try { await supabase.auth.signOut({ scope: 'local' }); } catch { /* ignore */ }
signedUpEmail.value = cleanEmail;
signedUp.value = true;
return;
}
toast.add({
severity: 'success',
summary: 'Conta criada',
detail: 'Agora vamos para os próximos passos.',
life: 2500
});
router.push({
path: '/auth/welcome',
query: {
plan: planFromQuery.value || undefined,
interval: intervalNormalized.value || undefined
}
});
// confirmação desligada (auto-confirm): o guard manda pra /onboarding,
// que provisiona o tenant gratuito.
toast.add({ severity: 'success', summary: 'Conta criada', detail: 'Preparando seu ambiente…', life: 2500 });
router.push('/onboarding');
} catch (err) {
console.error(err);
@@ -366,7 +395,23 @@ async function onSignup() {
<!-- RIGHT -->
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
<div class="max-w-md mx-auto">
<div class="text-2xl font-semibold">Criar conta</div>
<!-- Tela: confirme seu e-mail (confirmação ligada) -->
<div v-if="signedUp" class="text-center py-6">
<div class="h-14 w-14 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] mx-auto grid place-items-center">
<i class="pi pi-envelope text-2xl opacity-80" />
</div>
<div class="text-2xl font-semibold mt-4">Confirme seu e-mail</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-2 leading-relaxed">
Enviamos um link de confirmação para <span class="font-semibold">{{ signedUpEmail }}</span>.
Clique no link para ativar sua conta e entrar seu ambiente é criado automaticamente no primeiro acesso.
</div>
<Message severity="info" class="mt-4 text-left">Não recebeu? Verifique a caixa de spam. O link expira em 1 hora.</Message>
<Button label="Ir para o login" icon="pi pi-arrow-right" iconPos="right" class="w-full mt-4" @click="goLogin" />
</div>
<!-- Form de cadastro -->
<div v-else>
<div class="text-2xl font-semibold">Criar conta grátis</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
tem conta?
<a class="cursor-pointer font-medium" @click.prevent="goLogin">Entrar</a>
@@ -445,6 +490,40 @@ async function onSignup() {
<!-- Form -->
<div class="space-y-4">
<!-- Tipo de conta -->
<div>
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Você é</label>
<SelectButton v-model="accountKind" :options="kindOptions" optionLabel="label" optionValue="value" :allowEmpty="false" :disabled="loading" class="w-full" />
</div>
<!-- Seu nome -->
<div>
<FloatLabel variant="on">
<InputText id="signup_name" v-model="displayName" class="w-full" autocomplete="name" :disabled="loading" />
<label for="signup_name">Seu nome</label>
</FloatLabel>
</div>
<!-- Nome do negócio -->
<div>
<FloatLabel variant="on">
<InputText id="signup_business" v-model="businessName" class="w-full" :disabled="loading" />
<label for="signup_business">{{ accountKind === 'therapist' ? 'Nome do seu consultório' : 'Nome da clínica' }}</label>
</FloatLabel>
</div>
<!-- Slug (identificador definitivo) -->
<div>
<FloatLabel variant="on">
<InputText id="signup_slug" v-model="slug" class="w-full" :disabled="loading" @input="onSlugInput" @blur="onSlugInput" />
<label for="signup_slug">Identificador (definitivo)</label>
</FloatLabel>
<div v-if="slug" class="mt-1 text-xs" :class="slugOk ? 'text-emerald-600' : (slugStatus === 'checking' ? 'text-[var(--text-color-secondary)]' : 'text-orange-600')">
{{ slugMessage }}
</div>
<div v-else class="mt-1 text-xs text-[var(--text-color-secondary)]">Vira o endereço do seu ambiente. Escolha com calma não pra mudar depois.</div>
</div>
<div>
<FloatLabel variant="on">
<InputText id="signup_email" v-model="email" class="w-full" autocomplete="email" :disabled="loading" @keydown.enter.prevent="onSignup" />
@@ -475,14 +554,15 @@ async function onSignup() {
<div v-if="password && !passwordOk" class="mt-2 text-xs text-orange-600">Use pelo menos 6 caracteres.</div>
</div>
<Button label="CRIAR CONTA" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
<Button label="CRIAR CONTA GRÁTIS" class="w-full" severity="success" :loading="loading" :disabled="!canSubmit" icon="pi pi-arrow-right" @click="onSignup" />
<div class="text-xs text-center text-[var(--text-color-secondary)]">Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.</div>
<div class="text-xs text-center text-[var(--text-color-secondary)]">Plano gratuito ativado na hora, sem cartão. Você pode fazer upgrade quando quiser.</div>
<div class="text-xs text-center">
<a class="cursor-pointer text-[var(--text-color-secondary)] hover:underline" @click.prevent="goLogin"> tenho conta entrar </a>
</div>
</div>
</div>
</div>
</div>
</div>