Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce3612135 | |||
| eb9dcc714f | |||
| 6383a550a6 | |||
| c3dac5eeec | |||
| 9acce9c19d | |||
| 91b89b7b5d | |||
| 1082123967 | |||
| 2f72886d4b | |||
| 403b7234a9 | |||
| 52c34cf63a | |||
| f6470718b7 | |||
| 3730b71150 | |||
| d50073da1a | |||
| 03790ecb9e | |||
| cb153165c3 | |||
| c189906c58 | |||
| 5a87c29dd0 | |||
| a2f3b9fae4 | |||
| 1594dc9426 | |||
| 31c4f08451 | |||
| 12d5c3b6dc | |||
| a979bdf1de | |||
| a73b82fa86 |
@@ -1693,3 +1693,30 @@ 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
|
||||
|
||||
## [2026-06-13 22:30] session | Freemium F3 done (4 extras: blacklist, usuarios+notify, root_redirect, esqueci-email)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:10] session | Freemium F2 polish done (welcome email + vitrine free); F1/F2/F3 completos
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:30] session | Freemium F4 runbook de deploy (docs/DEPLOY_FREEMIUM_F4.md)
|
||||
Touched: Freemium PLG
|
||||
|
||||
## [2026-06-13 23:55] session | Runbook deploy schema-per-tenant (docs/DEPLOY_SCHEMA_PER_TENANT.md)
|
||||
Touched: Migracao Schema-per-Tenant, Freemium PLG
|
||||
|
||||
## [2026-06-14 00:10] session | Handoff completo (estado + riscos + testes) + 2 runbooks de deploy
|
||||
Touched: Freemium PLG, Migracao Schema-per-Tenant
|
||||
|
||||
## [2026-06-14 00:25] session | Stack reiniciado (confirmacao e-mail ON) + gotcha pgrst.db_schemas pos-restart
|
||||
Touched: Freemium PLG, Migracao Schema-per-Tenant
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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** ✅ DONE (2026-06-13) — 4 extras. DB/edge: `blacklist` (tabela + trigger BEFORE INSERT em auth.users + integra slug_disponivel motivo 'bloqueado'); `saas_list_account_owners()` (donos por tenant, dev-only) + `notify_all_devs` + trigger em subscriptions; `saas_app_config`/`get_root_redirect()`; edge `recover-access` (esqueci-email por slug → magic link, dica mascarada). Front: SaasUsuariosPage (/saas/usuarios, selo Novo 24h) + SaasAppConfigPage (/saas/app-config, blacklist CRUD + toggle root_redirect); esqueci-email dialog no Login; root_redirect no guard ("/" não-logado→/lp|/login, cache TTL); pegadinha #4 (notificationStore.reset no logout). Arquivos: manual/freemium_f3a/b/c + functions/recover-access. Build OK, DB testado em ROLLBACK. ⚠️ edge recover-access precisa deploy (F4).
|
||||
- **F2 polish** ✅ DONE (2026-06-13) — welcome email: edge `send-welcome-email` (dono do tenant, destinatário do JWT, SMTP global/sistema com defaults Mailpit; best-effort fire-and-forget no OnboardingPage só no provision novo). Vitrine: seed `plan_public`+bullets dos free (migration 20260613000007); Landingpage mostra "Grátis para sempre" via `isFreePlan`. ⚠️ send-welcome-email precisa deploy + envs SMTP no hosted (F4). Com isso **F2 está 100%**.
|
||||
- **F4** — Deploy (hosted, dirigido pelo Leonardo). **Runbook completo em `docs/DEPLOY_FREEMIUM_F4.md`** (commit 2f72886): pré-req #0 = schema-per-tenant no hosted antes; migrations 05/06/07 + 5 manual/freemium_f* + Auth dashboard + deploy das 2 edges + secrets SMTP + rebuild + smoke 8 passos + kill-switches.
|
||||
|
||||
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
|
||||
@@ -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,104 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3a — Blacklist de e-mails e slugs
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (cria trigger em auth.users + altera
|
||||
-- slug_disponivel, que é owned por supabase_admin).
|
||||
--
|
||||
-- Tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||
-- trigger BEFORE INSERT em auth.users (não só no front); suporta domínio inteiro
|
||||
-- com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||
-- Gerida por saas_admin (dev) em Configurações.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.blacklist (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('email','slug')),
|
||||
value text NOT NULL,
|
||||
note text,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (kind, value)
|
||||
);
|
||||
|
||||
-- normaliza value (lower+trim) sempre
|
||||
CREATE OR REPLACE FUNCTION public.blacklist_normalize()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.value := lower(trim(NEW.value));
|
||||
IF NEW.value = '' THEN RAISE EXCEPTION 'valor vazio'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS trg_blacklist_normalize ON public.blacklist;
|
||||
CREATE TRIGGER trg_blacklist_normalize BEFORE INSERT OR UPDATE ON public.blacklist
|
||||
FOR EACH ROW EXECUTE FUNCTION public.blacklist_normalize();
|
||||
|
||||
-- RLS: só saas_admin gere
|
||||
ALTER TABLE public.blacklist ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS blacklist_saas_admin ON public.blacklist;
|
||||
CREATE POLICY blacklist_saas_admin ON public.blacklist
|
||||
FOR ALL USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- helpers ----------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.is_email_blacklisted(p_email text)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.blacklist
|
||||
WHERE kind = 'email'
|
||||
AND value IN (
|
||||
lower(trim(p_email)),
|
||||
'@' || split_part(lower(trim(p_email)), '@', 2)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
ALTER FUNCTION public.is_email_blacklisted(text) OWNER TO supabase_admin;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.is_slug_blacklisted(p_slug text)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT EXISTS (SELECT 1 FROM public.blacklist WHERE kind = 'slug' AND value = lower(trim(p_slug)));
|
||||
$$;
|
||||
ALTER FUNCTION public.is_slug_blacklisted(text) OWNER TO supabase_admin;
|
||||
|
||||
-- trigger de bloqueio real no cadastro -----------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.enforce_email_blacklist()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.email IS NOT NULL AND public.is_email_blacklisted(NEW.email) THEN
|
||||
RAISE EXCEPTION 'EMAIL_BLOCKED' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.enforce_email_blacklist() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enforce_email_blacklist ON auth.users;
|
||||
CREATE TRIGGER trg_enforce_email_blacklist BEFORE INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_email_blacklist();
|
||||
|
||||
-- integra no slug_disponivel (motivo 'bloqueado') ------------------------------
|
||||
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;
|
||||
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 public.is_slug_blacklisted(v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'bloqueado'); END IF;
|
||||
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso'); END IF;
|
||||
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;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.blacklist TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,116 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3b — /saas/usuarios (donos por tenant) + notificação aos devs
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (lê auth.users.email + cria trigger em
|
||||
-- public.subscriptions; notify_user_sistema é chamada por SECURITY DEFINER).
|
||||
--
|
||||
-- • saas_list_account_owners(): 1 linha por tenant com o DONO (master),
|
||||
-- nome/slug/e-mail/plano + selo "novo" (24h). Dev-only (is_saas_admin).
|
||||
-- • notify_all_devs(): insere em notifications_sistema p/ cada saas_admin.
|
||||
-- • trigger em subscriptions: avisa os devs quando nasce/muda uma assinatura,
|
||||
-- com deeplink pra /saas/usuarios.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Donos por tenant (dev-only) ---------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_account_owners()
|
||||
RETURNS TABLE (
|
||||
tenant_id uuid,
|
||||
slug text,
|
||||
tenant_name text,
|
||||
kind text,
|
||||
owner_id uuid,
|
||||
owner_name text,
|
||||
owner_email text,
|
||||
plan_key text,
|
||||
created_at timestamptz,
|
||||
is_new boolean
|
||||
)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT t.id, t.slug::text, t.name::text, t.kind::text,
|
||||
owner.user_id, pr.full_name::text, au.email::text,
|
||||
COALESCE(vas.plan_key, ps.plan_key)::text,
|
||||
t.created_at,
|
||||
(t.created_at > now() - interval '24 hours')
|
||||
FROM public.tenants t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tm.user_id
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = t.id AND tm.role = 'tenant_admin' AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
) owner ON true
|
||||
LEFT JOIN public.profiles pr ON pr.id = owner.user_id
|
||||
LEFT JOIN auth.users au ON au.id = owner.user_id
|
||||
LEFT JOIN public.v_tenant_active_subscription vas ON vas.tenant_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.plan_key FROM public.subscriptions s
|
||||
WHERE s.user_id = owner.user_id AND s.status = 'active' AND s.tenant_id IS NULL
|
||||
ORDER BY s.created_at DESC LIMIT 1
|
||||
) ps ON true
|
||||
ORDER BY t.created_at DESC;
|
||||
END $$;
|
||||
ALTER FUNCTION public.saas_list_account_owners() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.saas_list_account_owners() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.saas_list_account_owners() TO authenticated, service_role;
|
||||
|
||||
-- 2) notify_all_devs ----------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.notify_all_devs(
|
||||
p_type text, p_payload jsonb, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL
|
||||
)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE r record; n int := 0;
|
||||
BEGIN
|
||||
FOR r IN SELECT user_id FROM public.saas_admins LOOP
|
||||
PERFORM public.notify_user_sistema(r.user_id, p_type, p_payload, NULL, p_ref_id, p_ref_table);
|
||||
n := n + 1;
|
||||
END LOOP;
|
||||
RETURN n;
|
||||
END $$;
|
||||
ALTER FUNCTION public.notify_all_devs(text, jsonb, uuid, text) OWNER TO supabase_admin;
|
||||
|
||||
-- 3) trigger em subscriptions -------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.trg_notify_devs_subscription()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_slug text; v_title text;
|
||||
BEGIN
|
||||
-- só em INSERT ou quando o status muda
|
||||
IF TG_OP = 'UPDATE' AND NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT t.slug INTO v_slug FROM public.tenants t WHERE t.id = NEW.tenant_id;
|
||||
|
||||
v_title := CASE WHEN TG_OP = 'INSERT' THEN 'Nova assinatura' ELSE 'Assinatura atualizada' END;
|
||||
|
||||
PERFORM public.notify_all_devs(
|
||||
'subscription_' || lower(TG_OP),
|
||||
jsonb_build_object(
|
||||
'title', v_title,
|
||||
'detail', NEW.plan_key || ' · ' || NEW.status || COALESCE(' · ' || v_slug, ''),
|
||||
'deeplink', '/saas/usuarios',
|
||||
'plan_key', NEW.plan_key,
|
||||
'status', NEW.status
|
||||
),
|
||||
NEW.id, 'subscriptions'
|
||||
);
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_notify_devs_subscription() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_subscriptions_notify_devs ON public.subscriptions;
|
||||
CREATE TRIGGER trg_subscriptions_notify_devs
|
||||
AFTER INSERT OR UPDATE OF status ON public.subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_notify_devs_subscription();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F3c — root_redirect (pra onde o visitante não-logado vai na raiz "/")
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (RLS por is_saas_admin).
|
||||
--
|
||||
-- Config singleton saas_app_config + RPC pública get_root_redirect() (anon lê o
|
||||
-- alvo: 'landing' | 'login'). O guard do front usa pra rotear "/". Só saas_admin
|
||||
-- altera (via UPDATE direto, gated por RLS).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.saas_app_config (
|
||||
id boolean PRIMARY KEY DEFAULT true, -- singleton: sempre id=true
|
||||
root_redirect text NOT NULL DEFAULT 'landing' CHECK (root_redirect IN ('landing','login')),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_app_config_singleton CHECK (id)
|
||||
);
|
||||
|
||||
INSERT INTO public.saas_app_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.saas_app_config ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS saas_app_config_read ON public.saas_app_config;
|
||||
CREATE POLICY saas_app_config_read ON public.saas_app_config FOR SELECT USING (true);
|
||||
DROP POLICY IF EXISTS saas_app_config_write ON public.saas_app_config;
|
||||
CREATE POLICY saas_app_config_write ON public.saas_app_config
|
||||
FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
GRANT SELECT ON public.saas_app_config TO anon, authenticated;
|
||||
GRANT UPDATE ON public.saas_app_config TO authenticated;
|
||||
|
||||
-- RPC pública: alvo do "/" pra visitante não-logado
|
||||
CREATE OR REPLACE FUNCTION public.get_root_redirect()
|
||||
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT root_redirect FROM public.saas_app_config WHERE id), 'landing');
|
||||
$$;
|
||||
ALTER FUNCTION public.get_root_redirect() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.get_root_redirect() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.get_root_redirect() TO anon, 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$;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F2 (polish) — apresentação do plano gratuito na vitrine pública
|
||||
--
|
||||
-- Os planos free já eram is_visible em v_public_pricing, mas sem plan_public
|
||||
-- (nome/descrição/bullets) e sem preço — renderizavam sem nome/valor. Este seed
|
||||
-- dá um cartão "Grátis" decente. Referência por KEY (subquery), idempotente.
|
||||
-- O preço "Grátis" é tratado no front (Landingpage isFreePlan).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
SELECT id, 'Grátis',
|
||||
'Comece sem custo: o essencial pra organizar sua agenda, pacientes e prontuário.',
|
||||
'Grátis', false, true, 0
|
||||
FROM public.plans WHERE key = 'clinic_free'
|
||||
ON CONFLICT (plan_id) DO UPDATE
|
||||
SET public_name = EXCLUDED.public_name,
|
||||
public_description = EXCLUDED.public_description,
|
||||
badge = EXCLUDED.badge,
|
||||
is_visible = true,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = now();
|
||||
|
||||
INSERT INTO public.plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
SELECT id, 'Grátis',
|
||||
'Pra terapeutas individuais: agenda, pacientes e prontuário sem custo.',
|
||||
'Grátis', false, true, 0
|
||||
FROM public.plans WHERE key = 'therapist_free'
|
||||
ON CONFLICT (plan_id) DO UPDATE
|
||||
SET public_name = EXCLUDED.public_name,
|
||||
public_description = EXCLUDED.public_description,
|
||||
badge = EXCLUDED.badge,
|
||||
is_visible = true,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = now();
|
||||
|
||||
-- bullets (idempotente: limpa os dos free e re-insere)
|
||||
DELETE FROM public.plan_public_bullets
|
||||
WHERE plan_id IN (SELECT id FROM public.plans WHERE key IN ('clinic_free','therapist_free'));
|
||||
|
||||
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||
FROM public.plans p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES
|
||||
('Agenda completa e prontuário', true, 1),
|
||||
('Até 30 pacientes ativos', false, 2),
|
||||
('Documentos e lembretes básicos', false, 3),
|
||||
('Agendamento online', false, 4)
|
||||
) AS b(text, highlight, sort_order)
|
||||
WHERE p.key = 'clinic_free';
|
||||
|
||||
INSERT INTO public.plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
SELECT p.id, b.text, b.highlight, b.sort_order
|
||||
FROM public.plans p
|
||||
CROSS JOIN LATERAL (
|
||||
VALUES
|
||||
('Agenda completa e prontuário', true, 1),
|
||||
('Até 20 pacientes ativos', false, 2),
|
||||
('Documentos e lembretes básicos', false, 3),
|
||||
('Agendamento online', false, 4)
|
||||
) AS b(text, highlight, sort_order)
|
||||
WHERE p.key = 'therapist_free';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,176 @@
|
||||
# Deploy F4 — Freemium / PLG (hosted)
|
||||
|
||||
> Runbook de produção do épico freemium/PLG (branch `feat/freemium-plg`).
|
||||
> Gerado em 2026-06-13. Faça **um passo de cada vez** e valide antes de seguir.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ PRÉ-REQUISITO #0 (bloqueante) — schema-per-tenant no hosted
|
||||
|
||||
O freemium foi construído **em cima** da migração schema-per-tenant. As RPCs
|
||||
(`auto_provision_free_tenant`, `slug_disponivel`, enforcement de limite, etc.)
|
||||
dependem de infra que **só existe se a schema-per-tenant já estiver no hosted**:
|
||||
|
||||
- `tenant_schemas`, `_tenant_template`, `clone_tenant_template`, `seed_*`
|
||||
- helpers `tenant_id_for_schema`, `tenant_schema_name`, `is_saas_admin`, `is_tenant_member`
|
||||
- exposição dinâmica de schemas no PostgREST (`pgrst.db_schemas`)
|
||||
- `v_tenant_active_subscription`, `notifications_sistema`, `tenant_members.slug`
|
||||
|
||||
**Se o hosted ainda está no modelo RLS (branch `main`), NÃO aplique o freemium —
|
||||
ele vai quebrar.** Ordem obrigatória:
|
||||
|
||||
1. Deployar e validar a **schema-per-tenant** no hosted (migrations `20260612*` +
|
||||
`20260613000001..000004` + os `manual/f5*..f6_2h*` + `f6_4`), **sem** o F6.3
|
||||
DROP num primeiro momento (dados espelhados em public — ver `database-novo/manual/f6_3_ROLLBACK.md`).
|
||||
2. Só então seguir este runbook do freemium.
|
||||
|
||||
> Enquanto a schema-per-tenant não estiver no hosted + testada no browser
|
||||
> (task #7 / DROP F6.3 pendente), este deploy fica **em espera**.
|
||||
|
||||
---
|
||||
|
||||
## Inventário do que vai pro hosted (freemium)
|
||||
|
||||
### Migrations (rodam como `postgres` via `supabase db push` ou SQL Editor)
|
||||
| Ordem | Arquivo | O quê |
|
||||
|---|---|---|
|
||||
| 1 | `database-novo/migrations/20260613000005_freemium_f1_therapist_free_patient_limit.sql` | `max_patients=20` no therapist_free |
|
||||
| 2 | `database-novo/migrations/20260613000006_fix_audit_global_tables.sql` | **fix regressão** audit em tenant_members (aplicar SEMPRE) |
|
||||
| 3 | `database-novo/migrations/20260613000007_freemium_f2_vitrine_free.sql` | cartão "Grátis" na vitrine (plan_public + bullets) |
|
||||
|
||||
### Manual `supabase_admin` (rodam com role elevada — ver nota de permissões)
|
||||
Aplicar **nesta ordem** (idempotentes; `BEGIN/COMMIT` internos):
|
||||
1. `database-novo/manual/freemium_f1_plan_limits.supabase_admin.sql`
|
||||
2. `database-novo/manual/freemium_f2_provisioning.supabase_admin.sql`
|
||||
3. `database-novo/manual/freemium_f3a_blacklist.supabase_admin.sql`
|
||||
4. `database-novo/manual/freemium_f3b_saas_owners_notify.supabase_admin.sql`
|
||||
5. `database-novo/manual/freemium_f3c_app_config.supabase_admin.sql`
|
||||
|
||||
### Edge functions
|
||||
- `supabase/functions/recover-access` (esqueci-email por slug → magic link)
|
||||
- `supabase/functions/send-welcome-email` (boas-vindas ao dono — best-effort)
|
||||
|
||||
### Config
|
||||
- Auth → **Confirm email = ON** + Site/Redirect URLs
|
||||
- Secrets de SMTP do `send-welcome-email` (+ `APP_URL`)
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo
|
||||
|
||||
### 1) Banco — migrations
|
||||
Com a CLI apontando pro projeto hosted (`supabase link` já feito):
|
||||
|
||||
```bash
|
||||
supabase db push # aplica as migrations pendentes (inclui as 3 do freemium)
|
||||
```
|
||||
Ou, se preferir manual, cole cada arquivo `migrations/2026061300000[567]_*.sql` no
|
||||
**SQL Editor** do dashboard (roda como `postgres`), na ordem da tabela acima.
|
||||
|
||||
> ⚠️ A `20260613000006_fix_audit_global_tables.sql` é **obrigatória** — sem ela,
|
||||
> qualquer novo `tenant_members` (provisionamento, convite) falha no hosted também.
|
||||
|
||||
### 2) Banco — manual `supabase_admin`
|
||||
|
||||
**Nota de permissões (hosted):** no Supabase hosted, o `postgres` da connection
|
||||
string tem mais privilégio que o local, mas o schema `auth` é de `supabase_admin`.
|
||||
A blacklist (`freemium_f3a`) cria **trigger em `auth.users`** e vários objetos são
|
||||
`ALTER FUNCTION ... OWNER TO supabase_admin`. Caminhos:
|
||||
- **SQL Editor do dashboard** roda como `postgres` (costuma conseguir criar trigger
|
||||
em `auth.users` no hosted) — tente por aí primeiro.
|
||||
- Se algum `OWNER TO supabase_admin` ou o trigger em `auth.users` falhar por permissão,
|
||||
rode via a connection string de **serviço** (Settings → Database → Connection string),
|
||||
ou abra ticket de acesso. Os `OWNER TO supabase_admin` podem ser trocados por
|
||||
`OWNER TO postgres` no hosted se necessário (sem perda funcional).
|
||||
|
||||
Aplicar os 5 arquivos `manual/freemium_f*.supabase_admin.sql` **na ordem**, colando
|
||||
no SQL Editor (cada um é uma transação). Verifique a saída sem erro a cada um.
|
||||
|
||||
**Smoke SQL pós-aplicação** (no SQL Editor):
|
||||
```sql
|
||||
select public.slug_disponivel('teste_slug_livre'); -- {ok:true}
|
||||
select public.get_root_redirect(); -- 'landing'
|
||||
-- como saas_admin (logado no dashboard você é postgres; teste a RPC existe):
|
||||
select proname from pg_proc where proname in
|
||||
('auto_provision_free_tenant','processar_pos_signup','slug_disponivel',
|
||||
'saas_list_account_owners','notify_all_devs','is_email_blacklisted','get_root_redirect');
|
||||
-- trigger de limite presente nos schemas:
|
||||
select count(*) from pg_trigger where tgname='enforce_patient_plan_limit';
|
||||
```
|
||||
|
||||
### 3) Auth — dashboard
|
||||
Authentication → **Providers / Email**:
|
||||
- **Confirm email = ON** (equivale ao `enable_confirmations=true` do config.toml local).
|
||||
- **Site URL** = origem do app em produção (ex.: `https://app.seudominio.com`).
|
||||
- **Redirect URLs** — adicionar (magic link + confirmação caem aqui):
|
||||
- `https://app.seudominio.com/onboarding`
|
||||
- `https://app.seudominio.com/auth/login`
|
||||
- `https://app.seudominio.com/**` (se preferir curinga)
|
||||
- SMTP do GoTrue (o que manda confirmação + magic link): garantir que está
|
||||
configurado com um provedor real (não Mailpit) em Authentication → Emails → SMTP.
|
||||
|
||||
### 4) Edge functions — deploy + secrets
|
||||
```bash
|
||||
supabase functions deploy recover-access
|
||||
supabase functions deploy send-welcome-email
|
||||
```
|
||||
`recover-access` usa só envs já injetadas (SUPABASE_URL / SERVICE_ROLE_KEY / ANON_KEY).
|
||||
|
||||
`send-welcome-email` usa um **SMTP de sistema** (defaults = Mailpit local). Em produção,
|
||||
configure os secrets pra um provedor real (pode ser o mesmo do GoTrue):
|
||||
```bash
|
||||
supabase secrets set \
|
||||
SMTP_HOST="smtp.seuprovedor.com" \
|
||||
SMTP_PORT="587" \
|
||||
SMTP_USER="..." \
|
||||
SMTP_PASS="..." \
|
||||
SMTP_FROM="no-reply@seudominio.com" \
|
||||
SMTP_FROM_NAME="Agência PSI" \
|
||||
APP_URL="https://app.seudominio.com"
|
||||
```
|
||||
> É best-effort: se faltar SMTP, o welcome só não envia — o onboarding/login segue.
|
||||
|
||||
### 5) Frontend — rebuild + deploy
|
||||
Build apontando pras envs do hosted (Supabase URL + anon key de produção):
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Publique o `dist/` no hosting de sempre. (A confirmação de e-mail é resolvida
|
||||
server-side; o front já trata o caso "sem sessão" → tela "confirme seu e-mail".)
|
||||
|
||||
### 6) Smoke test no hosted (fluxo completo)
|
||||
1. `/lp` → o cartão **Grátis** aparece na vitrine.
|
||||
2. **Criar conta grátis** → escolher slug (disponibilidade ao vivo) → enviar.
|
||||
3. Cai na tela **"confirme seu e-mail"** (não loga ainda).
|
||||
4. Abre o e-mail (provedor real) → clica no link → entra → `/onboarding` provisiona →
|
||||
painel do tenant. **Welcome email** chega (se SMTP configurado).
|
||||
5. Cadastrar pacientes até passar do limite → **toast PLAN_LIMIT_REACHED** + Upgrade PRO.
|
||||
6. Logar como **dev (saas_admin)** → `/saas/usuarios` lista o novo cliente com selo
|
||||
"Novo"; o **sino** recebeu "Nova assinatura".
|
||||
7. `/auth/login` → **"Esqueci meu e-mail"** com o slug → recebe magic link, dica mascarada.
|
||||
8. `/saas/app-config` → adicionar um e-mail na **blacklist** → tentar cadastrar com
|
||||
ele → bloqueado. Trocar **root_redirect** e conferir o destino de `/`.
|
||||
|
||||
---
|
||||
|
||||
## Rollback / kill-switch (se algo der errado)
|
||||
- **Confirmação de e-mail**: desligar "Confirm email" no dashboard volta ao signup
|
||||
sem confirmação (mas o signup novo já espera confirmação — prefira corrigir a frente).
|
||||
- **Enforcement de limite**: `DROP TRIGGER enforce_patient_plan_limit ON <schema>.patients`
|
||||
(ou ajustar `plan_features.limits` pra um número alto — vale em runtime, sem deploy).
|
||||
- **Blacklist**: `DROP TRIGGER trg_enforce_email_blacklist ON auth.users;`
|
||||
- **notify devs**: `DROP TRIGGER trg_subscriptions_notify_devs ON public.subscriptions;`
|
||||
- **root_redirect**: `UPDATE public.saas_app_config SET root_redirect='login';` (ou 'landing').
|
||||
- Tudo é **aditivo** — nenhuma tabela/coluna existente foi removida pelo freemium.
|
||||
|
||||
---
|
||||
|
||||
## Checklist rápido
|
||||
- [ ] schema-per-tenant já está no hosted e validada (PRÉ-REQUISITO #0)
|
||||
- [ ] migrations 05/06/07 aplicadas (`supabase db push`)
|
||||
- [ ] 5 manual/freemium_f*.supabase_admin.sql aplicados na ordem
|
||||
- [ ] Confirm email = ON + Site/Redirect URLs + SMTP do GoTrue
|
||||
- [ ] `recover-access` e `send-welcome-email` deployadas
|
||||
- [ ] secrets SMTP do `send-welcome-email` + `APP_URL`
|
||||
- [ ] frontend rebuildado e publicado
|
||||
- [ ] smoke test (8 passos) ✅
|
||||
@@ -0,0 +1,212 @@
|
||||
# Deploy — Migração Schema-per-Tenant (hosted)
|
||||
|
||||
> Runbook de produção da migração RLS-only → schema físico por tenant
|
||||
> (branch `feat/schema-per-tenant`). **Pré-requisito do freemium** (ver
|
||||
> `docs/DEPLOY_FREEMIUM_F4.md`). Gerado em 2026-06-13.
|
||||
>
|
||||
> ⚠️ Esta é a migração **mais delicada do projeto**: envolve migração de DADOS,
|
||||
> exposição dinâmica de schemas no PostgREST e um DROP **irreversível** no fim.
|
||||
> Faça em **janela de manutenção**, com backup fresco, um passo de cada vez.
|
||||
|
||||
---
|
||||
|
||||
## Estratégia de cutover (por que é seguro)
|
||||
|
||||
O desenho **COPIA** os dados (não move) de `public` pros schemas `tenant_<slug>` e
|
||||
só remove o espelho de `public` no **último** passo (F6.3 DROP). Durante a transição,
|
||||
os dados existem nos **dois lugares** → o código antigo (lê `public`) e o novo
|
||||
(lê `tenant_<slug>`) funcionam simultaneamente. Isso permite:
|
||||
|
||||
```
|
||||
estrutura aditiva → migra dados (copia) → sobe código novo → valida → (só então) DROP
|
||||
```
|
||||
|
||||
Se algo der errado **antes do DROP**, é só voltar o frontend/edge pra versão antiga
|
||||
(que lê `public`, intacto). O DROP é o único ponto de não-retorno.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risco hosted #1 — exposição dinâmica de schemas no PostgREST
|
||||
|
||||
Local: `refresh_pgrst_schemas()` faz `ALTER ROLE authenticator SET pgrst.db_schemas=...`
|
||||
+ `NOTIFY pgrst, 'reload config'` (config in-database, persiste em `pg_db_role_setting`).
|
||||
Um trigger em `public.tenant_schemas` re-roda isso a cada clone/drop.
|
||||
|
||||
No **Supabase hosted** isso precisa ser confirmado:
|
||||
- O hosted suporta a config in-DB do PostgREST, MAS a permissão de `ALTER ROLE
|
||||
authenticator` pode estar restrita à role de serviço. **Teste cedo** (Fase C):
|
||||
rode `select public.refresh_pgrst_schemas();` e cheque se os schemas tenant
|
||||
passam a responder via REST.
|
||||
- Fallback se o `ALTER ROLE` falhar no hosted: adicionar os schemas em
|
||||
**Dashboard → Project Settings → API → Exposed schemas** (lista). Problema: é
|
||||
**estática** — cada signup novo cria um schema que precisaria entrar na lista.
|
||||
Mitigação: manter o trigger in-DB (se funcionar) OU automatizar via Management API.
|
||||
**Decidir isso ANTES de abrir pra signup self-service.**
|
||||
|
||||
> Sem exposição dos schemas tenant, o app novo recebe 404/empty nas tabelas tenant.
|
||||
|
||||
---
|
||||
|
||||
## Inventário (branch `feat/schema-per-tenant`)
|
||||
|
||||
### Migrations (aditivas — rodam como `postgres` / `supabase db push`)
|
||||
Ordem natural por timestamp:
|
||||
```
|
||||
20260612000001_f1_tenants_slug.sql # tenants.slug + generate_tenant_slug + trigger
|
||||
20260612000002_f1_tenant_schema_helpers.sql # tenant_schema_name, tenant_id_for_schema, ...
|
||||
20260612000003_f1_tenant_template.sql # _tenant_template (78 tabelas, views, seeds)
|
||||
20260612000004_f1_clone_drop_functions.sql # clone_tenant_template, drop_tenant_schema, tenant_schemas, channel_routing
|
||||
20260612000005_f1_template_seed_whitelist.sql # limpa seeds órfãos
|
||||
20260612000006_f2_provision_clone.sql # provision_* chamam clone
|
||||
20260612000007_f3_my_tenants_slug.sql # my_tenants() retorna slug
|
||||
20260613000001_f1b_keep_anon_tables_public.sql# 6 tabelas anon ficam em public
|
||||
20260613000002_f5_pgrst_schemas_trigger.sql # trigger pgrst refresh em tenant_schemas
|
||||
20260613000003_f6_0_clone_existing_tenants.sql# clona os tenants já existentes
|
||||
20260613000004_f6_2a_attach_agnostic_triggers.sql # Lote A (triggers agnósticos)
|
||||
```
|
||||
> As 3 migrations `*_freemium_*` / `*_fix_audit_*` (000005/06/07) são do **freemium** —
|
||||
> aplicar só no deploy do freemium (depois). A `fix_audit` pode (e deve) vir já aqui se
|
||||
> for testar provisionamento, mas é inócua antes.
|
||||
|
||||
### Manual `supabase_admin` (privilegiadas — ordem obrigatória)
|
||||
```
|
||||
f5_pgrst_refresh_schemas.supabase_admin.sql # refresh_pgrst_schemas (ALTER ROLE authenticator)
|
||||
f6_2b_schema_aware_triggers.supabase_admin.sql# Lote B (14 trigger funcs schema-aware)
|
||||
f6_2c_notifications_split.supabase_admin.sql # Lote C (notifications_sistema + triggers)
|
||||
f6_2d_user_rpcs.supabase_admin.sql # Lote D (14 user RPCs + _tenant_route)
|
||||
f6_2e_cron_rpcs.supabase_admin.sql # Lote E (cron RPCs + _tenant_schema_unchecked)
|
||||
f6_2f_anon_token_rpcs.supabase_admin.sql # Lote F (anon/token RPCs)
|
||||
f6_2g_sql_to_plpgsql.supabase_admin.sql # Lote G (5 SQL→plpgsql)
|
||||
f6_2h_clone_wiring.supabase_admin.sql # wiring: tenants novos nascem com triggers
|
||||
f6_4_saas_admin_rpcs.supabase_admin.sql # SaaS-admin RPCs (feriados/notif/whatsapp)
|
||||
# DADOS:
|
||||
f6_1_migrate_data.supabase_admin.sql # cutover: COPIA dados public→schemas
|
||||
# DROP (último, gated):
|
||||
f6_3_drop_public_tenant_tables.supabase_admin.sql # 🛑 ponto de não-retorno
|
||||
```
|
||||
Rollback do DROP documentado em `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
|
||||
### Frontend / Edge (vão no rebuild + deploy)
|
||||
- `src/lib/supabase/tenantClient.js`, `src/composables/useTenantDb.js`, `tenantStore` (slug/schema getters), `notificationStore` (dual-source), e os `supabase.from(...)` → `tenantDb().from(...)` espalhados.
|
||||
- `supabase/functions/_shared/tenant.ts` + os webhooks/crons que passaram a rotear por schema.
|
||||
|
||||
### Config
|
||||
- `supabase/config.toml [api] schemas` permanece `["public","graphql_public"]` — os
|
||||
tenant são expostos **dinamicamente** (não na lista). Confirmar no hosted (Risco #1).
|
||||
|
||||
---
|
||||
|
||||
## Passo a passo
|
||||
|
||||
### Fase 0 — Pré-flight
|
||||
- [ ] **Backup completo** do hosted (dashboard → Database → Backups, ou `pg_dump`).
|
||||
- [ ] Confirmar que o hosted está no baseline (branch `main`/RLS) e estável.
|
||||
- [ ] Janela de manutenção combinada (a Fase D é cutover de dados).
|
||||
- [ ] Ter a connection string de **serviço** em mãos (algumas etapas exigem role elevada).
|
||||
|
||||
### Fase A — Estrutura aditiva (migrations)
|
||||
Aplicar as 11 migrations `20260612*`/`20260613000001..000004` (e a `fix_audit` 000006).
|
||||
Via `supabase db push` (com a branch linkada) ou colando no **SQL Editor** na ordem.
|
||||
São **aditivas** — criam slug, helpers, `_tenant_template`, funções de clone, registry
|
||||
`tenant_schemas`, e **clonam os tenants existentes** (000003 = f6_0). Não tocam dados.
|
||||
|
||||
**Verificar:**
|
||||
```sql
|
||||
select count(*) from public.tenant_schemas; -- = nº de tenants
|
||||
select tenant_schema_name((select id from tenants limit 1)); -- 'tenant_<slug>'
|
||||
select count(*) from information_schema.schemata where schema_name like 'tenant_%';
|
||||
```
|
||||
|
||||
### Fase B — Funções/triggers privilegiados (manual)
|
||||
Aplicar, **na ordem**, via connection string de serviço (ou SQL Editor se permitir):
|
||||
`f6_2b → f6_2c → f6_2d → f6_2e → f6_2f → f6_2g → f6_2h → f6_4`.
|
||||
(São CREATE OR REPLACE / idempotentes.)
|
||||
|
||||
> Vários fazem `ALTER FUNCTION ... OWNER TO supabase_admin`. Se a role disponível no
|
||||
> hosted não permitir, troque pra `OWNER TO postgres` (sem perda funcional) — mesma
|
||||
> nota do runbook do freemium.
|
||||
|
||||
### Fase C — PostgREST dinâmico (CRÍTICO — testar cedo)
|
||||
Aplicar `f5_pgrst_refresh_schemas.supabase_admin.sql` e disparar:
|
||||
```sql
|
||||
select public.refresh_pgrst_schemas(); -- seta pgrst.db_schemas + NOTIFY reload
|
||||
```
|
||||
**Teste real:** via REST (anon/auth key do hosted), bater numa tabela de um schema tenant
|
||||
(ex.: `GET /rest/v1/patients` com header `Accept-Profile: tenant_<slug>`). Deve responder
|
||||
(200/empty), não 404 "schema not exposed".
|
||||
- ✅ funcionou → seguir.
|
||||
- ❌ falhou (`ALTER ROLE authenticator` negado) → aplicar o **fallback** do Risco #1
|
||||
(Exposed schemas no dashboard) antes de prosseguir, e planejar a automação por signup.
|
||||
|
||||
### Fase D — Migração de DADOS (cutover, janela de manutenção)
|
||||
Aplicar `f6_1_migrate_data.supabase_admin.sql` (precisa `session_replication_role=replica`
|
||||
→ role de serviço). **COPIA** os dados public→schemas (idempotente, ON CONFLICT DO NOTHING).
|
||||
|
||||
**Verificar paridade** (por tabela/tenant — exemplo com `patients`):
|
||||
```sql
|
||||
-- public (origem) vs schema (destino) devem bater por tenant
|
||||
select t.slug,
|
||||
(select count(*) from public.patients p where p.tenant_id=t.id) as em_public,
|
||||
-- ajuste o schema dinamicamente / rode por tenant:
|
||||
null as em_schema
|
||||
from public.tenants t order by t.slug;
|
||||
-- e por schema: select count(*) from tenant_<slug>.patients;
|
||||
```
|
||||
Repetir o spot-check nas tabelas de maior volume (conversation_messages, financial_records, agenda_eventos).
|
||||
|
||||
### Fase E — Frontend + Edge (sobe o código novo)
|
||||
- Deploy das **edge functions** alteradas (`supabase functions deploy <nome>` pras que
|
||||
mudaram: webhooks twilio/evolution inbound, crons de fila, `_shared/tenant.ts` é embutido).
|
||||
- **Rebuild + publish do frontend** da branch (agora `tenantDb().from(...)` lê os schemas).
|
||||
- A partir daqui o app **lê/escreve nos schemas tenant**. Como os dados foram copiados na
|
||||
Fase D e `public` ainda existe, nada quebra mesmo se algum ponto antigo escapar.
|
||||
|
||||
### Fase F — Smoke test (app no modelo novo)
|
||||
- [ ] Login em 2-3 tenants distintos → agenda, pacientes, financeiro, conversas carregam.
|
||||
- [ ] Criar/editar registros → conferir que gravam em `tenant_<slug>` (não em `public`).
|
||||
- [ ] Notificações (sino) — dual-source (tenant + `notifications_sistema`).
|
||||
- [ ] Webhook inbound (twilio/evolution) grava no schema certo (roteamento por canal).
|
||||
- [ ] Crons (fila de notificação/email) varrem os tenants.
|
||||
- [ ] Provisionar um tenant NOVO de teste → nasce com schema + triggers (wiring f6_2h).
|
||||
- [ ] **Deixar rodando alguns dias** com os dados ainda espelhados em public (rede de segurança).
|
||||
|
||||
### Fase G — F6.3 DROP (🛑 PONTO DE NÃO-RETORNO)
|
||||
**Só depois** de F validada por dias + sem incidentes. Sequência:
|
||||
1. **Backup fresco obrigatório** (o header do f6_3 traz o `pg_dump --schema=public`).
|
||||
2. Reler `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
3. Aplicar `f6_3_drop_public_tenant_tables.supabase_admin.sql` (role de serviço):
|
||||
pré-flight asserts → 2 FK→coluna solta → drop 9 views → DROP CASCADE 78 tabelas public.
|
||||
4. Smoke test final. A partir daqui `public` não tem mais as tabelas tenant — só schemas.
|
||||
|
||||
---
|
||||
|
||||
## Rollback por fase
|
||||
- **Fases A–C** (estrutura/funções/pgrst): aditivas. Reverter = dropar os schemas/funções
|
||||
novos; `public` intacto, app antigo segue. Sem perda.
|
||||
- **Fase D** (dados): só copiou; reverter = ignorar/limpar schemas. `public` é a verdade.
|
||||
- **Fase E** (código): **rollback = redeploy do frontend/edge da versão antiga** (lê public).
|
||||
Esse é o botão de pânico até o DROP.
|
||||
- **Fase G** (DROP): irreversível sem restore. Rollback = restaurar do backup (ver
|
||||
`f6_3_ROLLBACK.md`). Por isso só após dias de validação.
|
||||
|
||||
---
|
||||
|
||||
## Ordem geral dos dois épicos
|
||||
```
|
||||
schema-per-tenant Fases A–F → (rodar dias) → schema-per-tenant Fase G (DROP)
|
||||
└─ freemium (DEPLOY_FREEMIUM_F4.md) pode entrar
|
||||
logo após as Fases A–F (não depende do DROP)
|
||||
```
|
||||
> O freemium **não** depende do DROP (F6.3) — depende da infra (Fases A–F). Dá pra subir
|
||||
> o freemium assim que o schema-per-tenant estiver validado no hosted, mantendo o espelho
|
||||
> em public como rede de segurança, e fazer o DROP com calma depois.
|
||||
|
||||
## Checklist
|
||||
- [ ] Fase 0: backup + janela + baseline confirmado
|
||||
- [ ] Fase A: 11 migrations aplicadas + verificação
|
||||
- [ ] Fase B: 9 manual (b→4) na ordem
|
||||
- [ ] Fase C: pgrst dinâmico testado via REST (ou fallback decidido)
|
||||
- [ ] Fase D: f6_1 + paridade de contagens conferida
|
||||
- [ ] Fase E: edges + frontend novos publicados
|
||||
- [ ] Fase F: smoke test + dias de soak
|
||||
- [ ] Fase G: backup fresco → DROP → smoke final
|
||||
@@ -0,0 +1,166 @@
|
||||
# Handoff — Onde paramos, Riscos e Passo a passo de teste
|
||||
|
||||
> Estado consolidado dos dois épicos grandes (schema-per-tenant + freemium/PLG).
|
||||
> Última atualização: 2026-06-13. Branch de trabalho: **`feat/schema-per-tenant`** (base)
|
||||
> e **`feat/freemium-plg`** (empilhada — contém tudo). `main` segue no modelo RLS antigo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onde paramos (estado atual)
|
||||
|
||||
### Branches
|
||||
- `main` — modelo RLS-only (produção atual). Recebeu só F0/F1/F2 aditivos da schema-per-tenant.
|
||||
- `feat/schema-per-tenant` — migração completa F0→F6.2 + wiring + F6.4. **F6.3 DROP NÃO aplicado.**
|
||||
- `feat/freemium-plg` — **ramificada da schema-per-tenant**, contém TODO o freemium (F1/F2/F3) +
|
||||
os dois runbooks de deploy + este handoff. **É a branch a deployar** (tem os dois épicos).
|
||||
|
||||
### Banco LOCAL (Docker `supabase_db_agenciapsi-primesakai`)
|
||||
Está no estado **schema-per-tenant + freemium aplicado**:
|
||||
- Schemas `tenant_<slug>` existem (9 tenants clonados) + dados COPIADOS (espelho ainda em `public`).
|
||||
- Todas as migrations + todos os `manual/*.supabase_admin.sql` aplicados, **EXCETO o F6.3 DROP**.
|
||||
- `enable_confirmations` está `true` no `config.toml` mas **só ativa após reiniciar o stack**.
|
||||
|
||||
### Schema-per-tenant — ✅ feito / ⏳ pendente
|
||||
- ✅ Estrutura, helpers, template, clone/drop, provisionamento, 66 funções migradas, dados dos 9 tenants copiados+verificados, PostgREST dinâmico (local), frontend/edge roteando por schema.
|
||||
- ⏳ **F6.3 DROP** (remove o espelho em `public`) — preparado, NÃO aplicado. Aguarda teste no browser + OK + backup fresco. (task #7)
|
||||
- 📄 Deploy: `docs/DEPLOY_SCHEMA_PER_TENANT.md`.
|
||||
|
||||
### Freemium/PLG — ✅ feito / ⏳ pendente
|
||||
- ✅ **F1** limite de pacientes (trigger runtime + toast + Upgrade PRO).
|
||||
- ✅ **F2** self-service (confirmação de e-mail, RPCs idempotentes, signup reescrito, /onboarding,
|
||||
welcome email, vitrine "Grátis") + **fix de regressão** do audit em `tenant_members`.
|
||||
- ✅ **F3** 4 extras (blacklist, /saas/usuarios + notify devs, esqueci-email, root_redirect).
|
||||
- ⏳ **F4 deploy** (hosted) — runbook em `docs/DEPLOY_FREEMIUM_F4.md`. Não deployado.
|
||||
- ⏳ **Teste local ponta-a-ponta** — exige reiniciar o stack (seção 3).
|
||||
|
||||
### Tudo commitado e pushado em `feat/freemium-plg`. Nada pendente no working tree
|
||||
(só `.env`/dashboard/`.claude` locais, intencionalmente fora).
|
||||
|
||||
---
|
||||
|
||||
## 2. Riscos (todos)
|
||||
|
||||
### 🔴 Críticos
|
||||
1. **PostgREST dinâmico no hosted** — a exposição de schemas tenant usa `ALTER ROLE
|
||||
authenticator SET pgrst.db_schemas`. Pode ser restrito no hosted. Se falhar, o app novo
|
||||
recebe 404 nas tabelas tenant. **Testar cedo** (Fase C do runbook); fallback = Exposed
|
||||
schemas no dashboard (estático → problema com signup self-service). **Decidir antes de
|
||||
abrir signup.**
|
||||
2. **F6.3 DROP é irreversível** — remove as tabelas tenant de `public`. Só após dias de soak
|
||||
no modelo novo + backup fresco. Rollback = restore (`f6_3_ROLLBACK.md`).
|
||||
3. **Confirmação de e-mail + SMTP do GoTrue (hosted)** — com `Confirm email = ON`, se o SMTP
|
||||
do GoTrue não estiver configurado com provedor real, **ninguém consegue logar** (o link de
|
||||
confirmação não chega). Configurar SMTP no dashboard ANTES de ligar a confirmação.
|
||||
|
||||
### 🟠 Importantes
|
||||
4. **Manual files fora do fluxo do `db.cjs`** — os `manual/*.supabase_admin.sql` NÃO são
|
||||
aplicados pelo `db.cjs migrate`. São aplicados à mão (psql como `supabase_admin`). Fácil
|
||||
esquecer um → função/trigger ausente. Os runbooks listam a ordem.
|
||||
5. **`postgres` não é superuser no stack local** — por isso vários objetos são `supabase_admin`.
|
||||
No hosted o `postgres` é mais privilegiado, mas o schema `auth` é de `supabase_admin`:
|
||||
o trigger da blacklist em `auth.users` e os `OWNER TO supabase_admin` podem precisar de
|
||||
SQL Editor ou troca pra `OWNER TO postgres`.
|
||||
6. **`config.toml` é gitignored** — `enable_confirmations=true` está só no arquivo local
|
||||
(não versionado). No hosted a confirmação vai pelo **dashboard** (Auth → Confirm email).
|
||||
7. **Migração de dados (cutover)** — `f6_1` COPIA; conferir **paridade de contagens** por
|
||||
tenant/tabela antes de confiar (e antes do DROP).
|
||||
8. **Edge functions novas precisam deploy** — `recover-access` e `send-welcome-email` (freemium)
|
||||
+ as edges de roteamento por schema (schema-per-tenant). Esquecer = esqueci-email/welcome/
|
||||
webhooks quebram.
|
||||
9. **Slug é IMUTÁVEL** — = nome do schema físico. Uma vez escolhido, não muda (trava em 3
|
||||
camadas). UX do signup deixa claro, mas é definitivo.
|
||||
|
||||
### 🟡 Menores / a observar
|
||||
10. **Enforcement de limite é por-linha** (BEFORE INSERT) — um bulk insert de pacientes numa
|
||||
única statement pode passar marginalmente do limite (cada linha não vê as anteriores da
|
||||
mesma statement). Na prática o cadastro é 1 a 1; ok.
|
||||
11. **notify_all_devs dispara a cada subscription** (inclui a free do auto_provision) — em
|
||||
escala, muitos avisos no sino do dev. Intencional; reavaliar se incomodar.
|
||||
12. **send-welcome-email usa SMTP de sistema** (separado do canal do tenant) — precisa secrets
|
||||
no hosted; é best-effort (falha não bloqueia login).
|
||||
13. **auto_provision idempotente** retorna o 1º tenant ativo se o user já tem algum — usuário
|
||||
multi-tenant que se cadastra de novo não ganha tenant novo (esperado).
|
||||
14. **Local vs main inconsistente** — o banco local está no modelo novo; o código da `main` é
|
||||
RLS. Se fizer `git checkout main`, o app antigo ainda funciona porque `public` tem as tabelas
|
||||
(até o DROP). Não rodar `main` esperando o modelo novo (e vice-versa).
|
||||
|
||||
---
|
||||
|
||||
## 3. Passo a passo — como testar TUDO (local)
|
||||
|
||||
> Pré: Docker do Supabase rodando (portas 643xx). Frontend via `npm run dev`.
|
||||
|
||||
### Passo 0 — Ativar a confirmação de e-mail
|
||||
A confirmação só vale após reiniciar o stack (o volume do banco **persiste** — nada se perde):
|
||||
```bash
|
||||
supabase stop && supabase start # se falhar com containers unhealthy, rode start de novo (transiente)
|
||||
```
|
||||
Conferir no Studio/Mailpit que está de pé. (Se preferir NÃO testar confirmação agora, pule —
|
||||
o front trata os dois casos; mas o fluxo "confirme e-mail" só aparece com isto ligado.)
|
||||
|
||||
> 🔴 **GOTCHA OBRIGATÓRIO pós-restart** — a GUC `pgrst.db_schemas` (exposição dos schemas
|
||||
> tenant no PostgREST) **NÃO sobrevive ao `supabase stop/start`** (o `start` reseta a role
|
||||
> `authenticator`). Sem isso o app dá **404** em todas as tabelas tenant. Rodar SEMPRE após start:
|
||||
> ```bash
|
||||
> docker exec -i supabase_db_agenciapsi-primesakai psql -U supabase_admin -h 127.0.0.1 -d postgres \
|
||||
> -c "select public.refresh_pgrst_schemas();"
|
||||
> ```
|
||||
> (Confirma exposição: `curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:64321/rest/v1/patients?limit=1" -H "Accept-Profile: tenant_<slug>"` deve dar 200.)
|
||||
|
||||
### Passo 1 — Schema-per-tenant: tenants EXISTENTES ainda funcionam
|
||||
1. `npm run dev`, logar num tenant existente (ex.: clínica Bem-Estar / um terapeuta).
|
||||
2. Abrir **Agenda, Pacientes, Financeiro, Conversas** → tudo carrega (lendo de `tenant_<slug>`).
|
||||
3. Criar/editar um registro (ex.: um bloqueio na agenda, editar um paciente) → salva sem erro.
|
||||
4. Sino de notificações abre (dual-source tenant + sistema).
|
||||
> Se algo não carregar, é sinal de roteamento de schema — anotar a tela/erro.
|
||||
|
||||
### Passo 2 — Freemium: signup self-service NOVO (o fluxo principal)
|
||||
1. Deslogar. Ir em **`/lp`** → conferir o cartão **"Grátis"** na vitrine.
|
||||
2. **Criar conta grátis** → escolher tipo (terapeuta/clínica) + seu nome + nome do negócio +
|
||||
**slug** (ver a checagem de disponibilidade ao vivo) + e-mail + senha.
|
||||
3. Submeter → cai na tela **"Confirme seu e-mail"** (NÃO loga ainda).
|
||||
4. Abrir o **Mailpit** (caixa de e-mail local) → achar o e-mail de confirmação → clicar no link.
|
||||
5. Voltar/entrar em **`/auth/login`** → logar → cai em **`/onboarding`** → "Preparando seu
|
||||
ambiente…" → provisiona → entra no painel do tenant novo.
|
||||
6. Conferir no Mailpit o **e-mail de boas-vindas** (welcome — best-effort).
|
||||
7. Conferir que o schema `tenant_<slug-escolhido>` foi criado (Studio) e que você é master.
|
||||
|
||||
### Passo 3 — Limite do plano gratuito
|
||||
1. No tenant gratuito recém-criado (ou num clinic_free existente), cadastrar pacientes.
|
||||
2. Ao passar do limite (clínica=30, terapeuta=20) → aparece o **toast "Limite do plano
|
||||
gratuito"** com botão **"Fazer upgrade"** (não o erro cru).
|
||||
3. Conferir o botão **"Upgrade PRO"** dourado no topbar (visível porque o plano é free).
|
||||
|
||||
### Passo 4 — SaaS / dev (logar como saas_admin)
|
||||
1. **`/saas/usuarios`** → o cliente novo aparece com selo **"Novo"** (verde, 24h), com slug/e-mail/plano.
|
||||
2. **Sino do dev** → recebeu **"Nova assinatura"** (do provisionamento).
|
||||
3. **`/saas/app-config`**:
|
||||
- Adicionar um **e-mail na blacklist** (ex.: `bloqueado@x.com`). Depois, deslogar e tentar
|
||||
**criar conta** com ele → bloqueado de verdade.
|
||||
- Testar **`@dominio.com`** (domínio inteiro).
|
||||
- Trocar **root_redirect** (landing↔login) e abrir **`/`** deslogado → confere o destino.
|
||||
|
||||
### Passo 5 — Esqueci meu e-mail
|
||||
1. **`/auth/login`** → **"Esqueci meu e-mail"** → digitar o **slug** do tenant criado no Passo 2.
|
||||
2. Recebe a confirmação com a **dica mascarada** (jo****@gm****.com) e um **magic link** no Mailpit.
|
||||
3. Clicar no magic link → entra. (O e-mail real nunca aparece na tela.)
|
||||
4. ⚠️ Edge functions locais: precisam estar servidas (`supabase functions serve` ou o runtime
|
||||
do stack). Se o esqueci-email/welcome não responder, é a edge não estar de pé localmente.
|
||||
|
||||
### Passo 6 — Pegadinha #4 (sino ao trocar de usuário)
|
||||
1. Logado como user A, com notificações no sino → **logout**.
|
||||
2. Logar como user B → o sino **não** mostra notificações do A (foi resetado no logout).
|
||||
|
||||
### Passo 7 (opcional, destrutivo, só quando confiante) — preparar o DROP
|
||||
NÃO aplicar agora. Quando tudo acima estiver validado por dias: seguir a **Fase G** do
|
||||
`docs/DEPLOY_SCHEMA_PER_TENANT.md` (backup fresco → `f6_3_drop_public_tenant_tables`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Atalhos / referências
|
||||
- Runbooks: `docs/DEPLOY_SCHEMA_PER_TENANT.md`, `docs/DEPLOY_FREEMIUM_F4.md`.
|
||||
- Rollback do DROP: `database-novo/manual/f6_3_ROLLBACK.md`.
|
||||
- Migrations: `database-novo/migrations/` (aplicar via `node database-novo/db.cjs migrate`).
|
||||
- Manual privilegiados: `database-novo/manual/*.supabase_admin.sql` (aplicar como `supabase_admin`).
|
||||
- Wiki: `Obsidian/Brain/wiki/Migracao Schema-per-Tenant.md` e `Obsidian/Brain/wiki/Freemium PLG.md`.
|
||||
- Portas locais: API 64321 · DB 64322 · Studio 64323 (stack shiftada +10000).
|
||||
+150
-338
@@ -1,338 +1,150 @@
|
||||
Prompt: Refactor Multi-Tenant para Schema-per-Tenant em Supabase
|
||||
Contexto e objetivo
|
||||
|
||||
Estou migrando meu sistema multi-tenant de RLS-only com tenant_id em cada tabela para schema-per-tenant (tenant_<slug>
|
||||
com clones físicos da estrutura). Quero isolamento físico das tabelas que pertencem a um tenant, mantendo em public
|
||||
apenas tabelas globais (auth.users, profiles, tenants, planos SaaS, notificações de sistema, etc.).
|
||||
|
||||
Já fiz esse refactor num projeto irmão (Vue 3 + Supabase + Postgres 17). Quero que você execute o mesmo aqui,
|
||||
considerando as lições que aprendi.
|
||||
|
||||
Antes de começar — varredura obrigatória
|
||||
|
||||
Não confie na lista que o usuário (ou um amigo programador) te entregar. Verifique tudo:
|
||||
|
||||
1. Liste TODAS as tabelas em public e classifique cada uma como "tenant-scoped" ou "global". Use a heurística: tem
|
||||
coluna tenant_id? É candidata a tenant-scoped. Mas reveja caso a caso — algumas globais (tenant_features,
|
||||
tenant_audit_log, support_messages) também têm tenant_id como FK e devem ficar em public.
|
||||
SELECT table_name,
|
||||
EXISTS(SELECT 1 FROM information_schema.columns c
|
||||
WHERE c.table_schema='public' AND c.table_name=t.table_name
|
||||
AND c.column_name='tenant_id') AS has_tenant_id
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
2. Liste TODAS as funções em public que referenciam essas tabelas-tenant. Não confie em listas pré-feitas — eu recebi
|
||||
"29 funções" e eram na verdade 52. Use:
|
||||
WITH tenant_tabs AS (SELECT unnest(ARRAY[/* sua lista */]) AS tab)
|
||||
SELECT DISTINCT p.proname, p.prokind, l.lanname
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
JOIN pg_language l ON l.oid = p.prolang
|
||||
CROSS JOIN tenant_tabs t
|
||||
WHERE n.nspname='public'
|
||||
AND pg_get_functiondef(p.oid) ~ ('\m' || t.tab || '\M')
|
||||
ORDER BY 1;
|
||||
3. Liste FKs cross-schema (de tabelas que vão ficar em public, apontando pras que vão sair). Se houver, planeje
|
||||
cuidado especial.
|
||||
4. Liste todas as edge functions e grep cada uma por .from('<tabela_tenant>').
|
||||
5. Liste as policies RLS que usam funções a refatorar — vão precisar ser dropadas/recriadas.
|
||||
|
||||
Plano de execução em fases
|
||||
|
||||
F0 — Categorização (não codar nada ainda)
|
||||
|
||||
Faça as listagens acima. Salve em documento markdown na raiz: docs/F0_categorizacao.md. Conte tabelas, funções, edge
|
||||
functions, FKs cross-schema, policies dependentes. Pause e mostre pro usuário antes de seguir.
|
||||
|
||||
F1 — Template + helpers
|
||||
|
||||
- Crie schema _tenant_template com TODAS as tabelas tenant-scoped clonadas SEM a coluna tenant_id (compostos unique
|
||||
também perdem tenant_id). Inclua índices, FKs locais, sequences, constraints.
|
||||
- Crie helpers em public:
|
||||
- tenant_schema_name(slug text) → text (IMMUTABLE) — converte slug→nome de schema sanitizado.
|
||||
- tenant_schema_for(tenant_id uuid) → text (STABLE) — busca slug e devolve schema.
|
||||
- tenant_id_for_schema(schema text) → uuid (STABLE) — inverso. CRÍTICO pra triggers que precisam descobrir o
|
||||
tenant_id (porque a coluna não existe mais nas tabelas tenant).
|
||||
- current_tenant_schema() → text (STABLE SECURITY DEFINER) — lê profiles.tenant_id do auth.uid() e devolve o schema
|
||||
dele.
|
||||
- clone_tenant_template(slug) → void (SECURITY DEFINER) — clona o template pra um schema novo.
|
||||
- drop_tenant_schema(tenant_id) → void — proteção: assert que target LIKE 'tenant_%' antes de DROP CASCADE.
|
||||
|
||||
F2 — Provisionamento
|
||||
|
||||
- Adapte sua função/edge provision_from_intent (ou equivalente) pra chamar clone_tenant_template(slug) quando criar
|
||||
tenant novo.
|
||||
- Confirme que policies padrão são criadas no schema clonado (uma policy tenant_member_full TO authenticated filtrando
|
||||
por profiles.tenant_id = '<id-do-tenant>').
|
||||
|
||||
F3 — Frontend: composable de acesso tenant
|
||||
|
||||
- Crie useTenantDb.js:
|
||||
export function useTenantDb() {
|
||||
const { perfil } = useAuth();
|
||||
const schemaName = computed(() => tenantSchemaName(perfil.value?.tenant_slug));
|
||||
const isReady = computed(() => Boolean(schemaName.value));
|
||||
function db() {
|
||||
if (!schemaName.value) throw new Error('tenant não disponível');
|
||||
return supabase.schema(schemaName.value);
|
||||
}
|
||||
return { db, schemaName, isReady };
|
||||
}
|
||||
- Faça find/replace amplo: supabase.from('<tenant_table>') → db().from('<tenant_table>') em todas as
|
||||
views/components/composables que tocam tabelas tenant.
|
||||
|
||||
F4 — Edge functions
|
||||
|
||||
Padrão pra qualquer edge function que precisa acessar tabela tenant:
|
||||
const userClient = createClient(SUPABASE_URL, ANON_KEY, {
|
||||
global: { headers: { Authorization: authHeader } }
|
||||
});
|
||||
const { data: tenantSchema } = await userClient.rpc('current_tenant_schema');
|
||||
const tenantDb = userClient.schema(tenantSchema as string);
|
||||
await tenantDb.from('oficios').update(...).eq(...);
|
||||
Tabelas globais (profiles, tenants, addon_*, support_*, etc.) seguem usando userClient.from(...) direto.
|
||||
|
||||
F5 — Expor schemas no PostgREST
|
||||
|
||||
Edite supabase/config.toml:
|
||||
[api]
|
||||
schemas = ["public", "graphql_public", "tenant_<slug1>", "tenant_<slug2>", ...]
|
||||
extra_search_path = ["public", "extensions"]
|
||||
Restart Supabase. Toda criação de tenant novo precisa atualizar este array e restartar PostgREST — automatize via
|
||||
migration que regenera config.toml, ou aceite gerenciamento manual.
|
||||
|
||||
F6 — Rewrite funções + drop tabelas em public (a fase mais perigosa)
|
||||
|
||||
Divida em lotes pequenos e teste cada um:
|
||||
|
||||
Lote 1 — split de notifications
|
||||
|
||||
Caso especial crítico. Antes do split, identifique:
|
||||
- Tipos de notif que cruzam tenants (dev recebe de todos os tenants, support_reply enviado pelo dev pro tenant,
|
||||
system_alert global).
|
||||
- Tipos que são puramente tenant-local (voucher_gerado, os_atribuida, oficio_assinado, prazos).
|
||||
|
||||
Decisão estrutural: notifications precisa virar duas tabelas:
|
||||
- tenant_<slug>.notifications — locais do tenant.
|
||||
- public.notifications_sistema — cross-tenant (SaaS pro tenant, ou pro dev).
|
||||
|
||||
Migration faz:
|
||||
1. Cria public.notifications_sistema (mesma estrutura + RLS própria + adiciona à publication realtime).
|
||||
2. Migra dados: INSERT INTO notifications_sistema SELECT ... WHERE type IN (cross_tenant_types), depois loop por
|
||||
tenant INSERT INTO tenant_X.notifications SELECT ... WHERE tenant_id = X AND type IN (local_types).
|
||||
3. Refatora todas as funções de notif (notify_user, notify_user_sistema, notify_tenant_admins, notify_all_devs,
|
||||
mark/archive_*) — duas variantes (_sistema_ em public, outras EXECUTE format pro schema tenant).
|
||||
4. DROP TABLE public.notifications.
|
||||
5. Frontend useNotifications.js: lê das duas fontes em paralelo, mescla por created_at DESC, cada item ganha campo
|
||||
_origem: 'tenant' | 'sistema'. Realtime em 2 canais. markRead/archive roteiam pra RPC correta via _origem.
|
||||
|
||||
Lote 2-4 — refator das demais funções
|
||||
|
||||
Padrão pra TRIGGER em tabela tenant:
|
||||
CREATE OR REPLACE FUNCTION public.trg_xxx() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tenant_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA); -- só se precisar
|
||||
-- ... lógica com tabelas tenant SEM prefixo `public.` ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC chamada por user logado em um tenant:
|
||||
CREATE OR REPLACE FUNCTION public.minha_rpc(...) RETURNS ...
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text := public.current_tenant_schema();
|
||||
BEGIN
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'sem tenant'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
-- ... lógica ...
|
||||
END $$;
|
||||
|
||||
Padrão pra RPC global (cron, dev, varre múltiplos tenants):
|
||||
FOR t_row IN SELECT id, slug FROM public.tenants WHERE ativo = true LOOP
|
||||
v_schema := public.tenant_schema_name(t_row.slug);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN CONTINUE; END IF;
|
||||
EXECUTE format('UPDATE %I.tabela ...', v_schema);
|
||||
END LOOP;
|
||||
|
||||
Padrão pra função que escreve no schema de OUTRO tenant (notify_user com p_tenant_id, etc.):
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema NOT LIKE 'tenant_%' THEN RETURN; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (...) VALUES ($1, $2, ...)', v_schema)
|
||||
USING ...;
|
||||
|
||||
Lote 4.5 — migração de DADOS (esqueci de avisar primeiro, vai se ferrar)
|
||||
|
||||
ESSE É O ERRO MAIS COMUM: o template clona estrutura, mas você esquece dos DADOS. Depois descobre que
|
||||
tenant_sindspam.os está vazio porque você nunca migrou. Faça uma migration que:
|
||||
|
||||
SET session_replication_role = replica; -- desabilita FK checks
|
||||
DO $$
|
||||
DECLARE
|
||||
tenant_id_target uuid := '...';
|
||||
tenant_schema text := 'tenant_...';
|
||||
tabs text[] := ARRAY[/* lista */];
|
||||
t text;
|
||||
v_cols text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
-- Lista colunas do schema tenant (sem tenant_id já)
|
||||
SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = tenant_schema AND table_name = t;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=t AND column_name='tenant_id') THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t, tenant_id_target);
|
||||
ELSE
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ON CONFLICT DO NOTHING',
|
||||
tenant_schema, t, v_cols, v_cols, t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- Reset sequences:
|
||||
FOR r IN SELECT t.table_name, c.column_name FROM information_schema.tables t
|
||||
JOIN information_schema.columns c ON c.table_schema=t.table_schema AND c.table_name=t.table_name
|
||||
WHERE t.table_schema=tenant_schema AND c.data_type='bigint' AND c.column_default LIKE 'nextval(%' LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', tenant_schema, r.table_name), r.column_name);
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0))',
|
||||
v_seq, r.column_name, tenant_schema, r.table_name);
|
||||
END LOOP;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Lote 5 — DROP CASCADE das tabelas em public
|
||||
|
||||
Só depois de TODAS as funções refatoradas e dados migrados:
|
||||
SET session_replication_role = replica;
|
||||
DO $$ BEGIN
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=t) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', t);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
SET session_replication_role = origin;
|
||||
|
||||
Limitações conhecidas e workarounds
|
||||
|
||||
1. PostgREST não suporta embed FK cross-schema
|
||||
|
||||
Você vai pagar esse pato. O PostgREST 14.x não consegue resolver embeds tipo db().from('os').select('*,
|
||||
profiles!os_solicitante_profile_id_fkey(nome)') quando os está em tenant_X e profiles em public, mesmo com FK física
|
||||
existindo. Mensagem: PGRST200: Could not find a relationship between 'os' and 'profiles' in the schema cache.
|
||||
|
||||
Solução: helper de "fake embed" no frontend. Crie useProfileEmbed.js:
|
||||
export async function attachProfiles(rows, mappings, columns = 'id, nome, email, role') {
|
||||
if (!rows?.length) return rows;
|
||||
const allIds = new Set();
|
||||
for (const m of mappings) rows.forEach(r => { if (r?.[m.idField]) allIds.add(r[m.idField]); });
|
||||
const { data } = await supabase.from('profiles').select(columns).in('id', [...allIds]);
|
||||
const map = new Map((data || []).map(p => [p.id, p]));
|
||||
return rows.map(r => {
|
||||
const out = { ...r };
|
||||
for (const m of mappings) out[m.alias] = r?.[m.idField] ? map.get(r[m.idField]) || null : null;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
// Variantes: attachProfilesNested(rows, nestedKey, mappings), attachProfilesById(rows, idField, alias)
|
||||
Faz 2 queries + merge em JS. Toda tela que tinha profiles!fkey(...) precisa virar duas queries + attach.
|
||||
|
||||
2. %ROWTYPE de tabelas tenant
|
||||
|
||||
Funções que declaravam v_plano public.convenio_planos%ROWTYPE quebram quando a tabela some do public. Troque por
|
||||
RECORD em todas. Quando precisar retornar tabela (RETURNS os_problemas), troque por RETURNS jsonb e construa via
|
||||
jsonb_build_object(...).
|
||||
|
||||
3. SQL functions com SET search_path TO 'public' declarado
|
||||
|
||||
Algumas funções são LANGUAGE sql com declaração estática SET search_path TO 'public'. Não dá pra usar set_config
|
||||
dinâmico em SQL puro. Converta pra LANGUAGE plpgsql. Atenção: isso exige DROP + CREATE (CREATE OR REPLACE não muda
|
||||
linguagem) → se tiver policy dependendo da função, drope a policy primeiro.
|
||||
|
||||
4. Triggers de notif que filtram cada destinatário
|
||||
|
||||
notify_tenant_admins insere em múltiplos owners via SELECT ... FROM profiles WHERE role IN (...). Pra respeitar
|
||||
preferências individuais, adicione AND public.should_notify(p.id, p_type) no WHERE.
|
||||
|
||||
5. Realtime
|
||||
|
||||
- A tabela notifications_sistema precisa ser adicionada explicitamente à publication: ALTER PUBLICATION
|
||||
supabase_realtime ADD TABLE public.notifications_sistema.
|
||||
- Canais realtime no frontend precisam do schema correto: { event: '*', schema: 'tenant_<slug>', table:
|
||||
'notifications', filter: 'owner_id=eq.X' } — não mais schema: 'public'.
|
||||
|
||||
6. Filtros .eq('tenant_id', X) no frontend
|
||||
|
||||
Após o split, qualquer db().from('tabela_tenant').eq('tenant_id', X) quebra com column tenant_id does not exist — a
|
||||
coluna sumiu. Faça grep e remova esses filtros (o isolamento agora é pelo schema). Mantenha em tabelas que ficam em
|
||||
public (tenant_features, tenant_audit_log, profiles).
|
||||
|
||||
7. session_replication_role na migração de dados
|
||||
|
||||
INSERTs em massa com FKs entre tabelas tenant podem falhar por ordem topológica. SET session_replication_role =
|
||||
replica desabilita checks de FK durante o INSERT. Lembre de voltar pra origin ao final.
|
||||
|
||||
8. Reset de sequences
|
||||
|
||||
Tabelas tenant com id bigint generated by sequence precisam de setval pós-migração — senão próximo INSERT vai colidir
|
||||
com PKs existentes.
|
||||
|
||||
9. Policies que usam funções refatoradas
|
||||
|
||||
unidade_in_current_tenant(uuid) aparecia como USING (...) em policies de public.prestador_unidade_acessos. Antes de
|
||||
DROP+CREATE da função, dropei as 2 policies. Tabelas que vão sumir não precisam recriar policy. Se a função é usada em
|
||||
policies de tabelas que ficam, recrie a policy depois.
|
||||
|
||||
10. FKs de tabelas que ficam em public apontando pras que saem
|
||||
|
||||
Antes de DROP, rode query pra detectar. Se houver, decida: migra a tabela referenciadora pro tenant também, ou
|
||||
converte FK pra coluna solta sem constraint.
|
||||
|
||||
Frontend — refactor sistemático
|
||||
|
||||
1. Find/replace em massa: supabase.from('<lista_tabelas_tenant>') → db().from(...). Importe useTenantDb.
|
||||
2. Caça por .eq('tenant_id': remova nos from('<tenant_table>'), mantenha nos from('<public_table>').
|
||||
3. Caça por embed profiles!fkey(...) em queries de tabelas tenant: refatore com attachProfiles.
|
||||
4. Caça por subscribeRealtime com schema: 'public' pra tabelas que viraram tenant — troque pra schema:
|
||||
tenantSchemaName(slug).
|
||||
5. Composables/serviços que usam supabase.from(...) em vez de db() direto: idem.
|
||||
|
||||
Backups e segurança
|
||||
|
||||
Sempre faça backup antes de cada lote:
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=public --no-owner --no-acl >
|
||||
backups/pre-loteN/public.sql
|
||||
docker exec supabase_db_<projeto> pg_dump -U postgres -d postgres --schema=tenant_<slug> --no-owner --no-acl >
|
||||
backups/pre-loteN/tenant_<slug>.sql
|
||||
|
||||
Pra recarregar cache do PostgREST após mudanças:
|
||||
docker exec supabase_db_<projeto> psql -U postgres -d postgres -c "NOTIFY pgrst, 'reload schema'"
|
||||
|
||||
Se mudou config.toml (schemas expostos), restart obrigatório:
|
||||
docker restart supabase_rest_<projeto>
|
||||
|
||||
Checklist final por lote
|
||||
|
||||
Antes de marcar um lote como concluído:
|
||||
- Migration aplica sem erro (psql -v ON_ERROR_STOP=1)
|
||||
- Smoke test SQL chamando as funções refatoradas via SET LOCAL request.jwt.claim.sub
|
||||
- NOTIFY pgrst, 'reload schema' rodado
|
||||
- Usuário testou as telas do FE que tocam essas funções
|
||||
- Sem erros novos no console do navegador (network 4xx/5xx, PGRST200, etc.)
|
||||
|
||||
Como interagir comigo durante o trabalho
|
||||
|
||||
- Antes de codar qualquer fase, mostre o plano resumido e pergunte se prossegue.
|
||||
- Para decisões estruturais (ex: notifications split, função X retorna jsonb ou record composto, drop CASCADE de
|
||||
policy órfã), use perguntas múltipla escolha — não decida sozinho.
|
||||
- Ao terminar um lote, sumarize o que mudou + lista de coisas pra eu testar no FE.
|
||||
- Não confie em listas pré-feitas (suas ou do usuário). Sempre re-confirme via query no banco.
|
||||
- Backup antes de cada DROP destrutivo.
|
||||
- PostgREST cache é teimoso — NOTIFY pgrst resolve tabelas/funções; restart do container pra mudanças de config.toml.
|
||||
---
|
||||
|
||||
# TAREFA: Implementar modelo freemium/PLG (plano gratuito self-service + Upgrade PRO)
|
||||
|
||||
Você vai transformar o caminho de aquisição de assinatura deste SaaS multi-tenant
|
||||
em um modelo freemium/PLG, igual ao que já fiz num sistema irmão. O objetivo:
|
||||
qualquer visitante cria uma conta gratuita sozinho, confirma o e-mail, e o ambiente
|
||||
do tenant é provisionado automaticamente — sem dev no meio. Plano gratuito limitado
|
||||
+ botão "Upgrade PRO" no topo.
|
||||
|
||||
IMPORTANTE: este sistema é PARECIDO mas NÃO idêntico ao de referência. NÃO assuma
|
||||
nomes de tabelas/funções/rotas. Antes de QUALQUER código, faça a fase de descoberta
|
||||
e me apresente o mapa + as decisões pra eu confirmar. Trabalhe em fases, commitando
|
||||
por assunto, e validando cada migration no banco local em transação com ROLLBACK
|
||||
antes de seguir. Rode o build a cada bloco de frontend.
|
||||
|
||||
## FASE 0 — DESCOBERTA (não codar ainda; me devolva um mapa com file:line)
|
||||
Mapeie e me explique como funciona hoje:
|
||||
1. Landing page / vitrine de planos e como o signup é acionado (query params? rota?).
|
||||
2. Fluxo de signup: componente, se usa supabase.auth.signUp direto ou um wrapper,
|
||||
o que cria (auth user, profile, tenant, subscription). Existe trigger
|
||||
handle_new_user em auth.users? Onde o profile nasce e com qual role default?
|
||||
3. Modelo de planos SaaS: tabelas (plans, plan_prices, plan_features, plan_limits,
|
||||
subscriptions, subscription_intents...), e o catálogo de features atual (LEIA
|
||||
DO BANCO, não de seeds antigos — o catálogo costuma divergir do seed inicial).
|
||||
4. Feature gating: como uma feature é checada (composable hasFeature? guard de
|
||||
rota com meta.feature? filtro de menu?).
|
||||
5. Enforcement de limites por plano: existe? (na maioria das vezes plan_limits
|
||||
está semeado mas NINGUÉM lê — confirme).
|
||||
6. Provisionamento de tenant: como um tenant nasce hoje (função provision_*?),
|
||||
é manual (dev) ou automático? É multi-tenant por RLS ou schema-per-tenant?
|
||||
Se schema-per-tenant: existe clone_tenant_schema/tenant_schema_name? O clone
|
||||
copia triggers do template?
|
||||
7. Fluxo de auth: onde o profile é carregado no login (carregarPerfil?), onde o
|
||||
guard decide pra onde mandar o usuário (roleHomePath), e o que acontece com um
|
||||
usuário logado SEM tenant.
|
||||
8. Infra de e-mail: como e-mails transacionais são enviados (Resend? SMTP? edge
|
||||
function?). Existe tabela de templates + algum render de {{var}}? O e-mail do
|
||||
GoTrue (confirmação) funciona? Existe pg_net?
|
||||
9. Infra de billing/pagamento (AsaaS/Stripe?): existe checkout de assinatura
|
||||
RECORRENTE em nível de plano, ou só cobrança avulsa? Onde está o webhook?
|
||||
|
||||
## FASE 0.5 — DECISÕES (me apresente como perguntas; estes são os defaults que
|
||||
## funcionaram bem, com o porquê):
|
||||
- Provisionamento: AUTO, mas só DEPOIS de confirmar o e-mail (anti-spam: cada
|
||||
signup pode clonar dezenas de tabelas).
|
||||
- Funil: manter os dois caminhos (free self-service + pago via intent/comercial).
|
||||
- Upgrade PRO: checkout self-service (reusar infra de pagamento existente) — mas
|
||||
isso é FASE 3, deferida; no início o botão abre o canal comercial.
|
||||
- Trial: o "free para sempre" substitui o trial.
|
||||
- No limite: BLOQUEIA a inserção no banco (trigger) + toast amigável com CTA.
|
||||
- Slug do sindicato: a pessoa escolhe (sugestão automática a partir do nome,
|
||||
sanitizado), com checagem de disponibilidade ao vivo, e é IMUTÁVEL (se for
|
||||
schema-per-tenant, o slug É o nome do schema → trocar órfã tudo; trave em 3
|
||||
camadas: sem UI, guard no banco rejeitando UPDATE, validação na criação).
|
||||
|
||||
## FASE 1 — Fundação do plano gratuito
|
||||
1. Migration: criar plano `gratuito` (preço 0) + plan_features (tudo ON menos o
|
||||
módulo premium, ex: ordem_de_servico) + plan_limits (ex: 50 associados).
|
||||
REGRA DE OURO: referencie features POR KEY via subquery, NUNCA por uuid
|
||||
hardcoded (uuids de features geradas em runtime divergem entre ambientes).
|
||||
Deixe o plano OCULTO na vitrine nesta fase (self-service ainda não existe).
|
||||
2. Enforcement de limite GENÉRICO: uma função trigger que resolve o tenant pelo
|
||||
contexto (no schema-per-tenant: pelo nome do schema = TG_TABLE_SCHEMA; no
|
||||
RLS: pelo tenant_id), lê o plano ativo + plan_limits EM RUNTIME (pra mudar o
|
||||
número no painel valer sem deploy), conta linhas vivas e dá RAISE com um código
|
||||
parseável tipo 'PLAN_LIMIT_REACHED|<feature>|<limite>'. Trigger BEFORE INSERT
|
||||
na tabela limitada. Se schema-per-tenant: coloque no template E faça backfill
|
||||
nos schemas já existentes. Teste: 50 passam, 51º bloqueia; tenant pago intacto.
|
||||
3. Frontend: helper que traduz o erro PLAN_LIMIT_REACHED em toast amigável com
|
||||
CTA de upgrade, usado em TODOS os pontos de insert da tabela limitada. Botão
|
||||
"Upgrade PRO" no topbar quando o plano do tenant for 'gratuito'.
|
||||
|
||||
## FASE 2 — Self-service com confirmação de e-mail
|
||||
1. LIGUE a confirmação de e-mail (enable_confirmations=true no config.toml E no
|
||||
dashboard do hosted).
|
||||
2. ⚠️ PEGADINHA CRÍTICA #1: com confirmação ligada, o signup NÃO tem sessão. Então
|
||||
TUDO que dependia de auth.uid()/JWT no signup QUEBRA em silêncio:
|
||||
- inserir subscription_intents (RLS exige jwt email = email da linha) → erro.
|
||||
- registrar aceite legal (LGPD) → não grava.
|
||||
SOLUÇÃO: NÃO faça esses efeitos no signup. Grave a escolha (plan_key, interval,
|
||||
nome/slug do sindicato, ids das versões legais aceitas) no raw_user_meta_data
|
||||
do signUp, e processe TUDO no 1º login pós-confirmação, via RPCs idempotentes:
|
||||
- auto_provision_free_tenant() (lê metadata, cria tenant, provisiona, vira
|
||||
master, cria subscription gratuita ativa) — chamada em carregarPerfil quando
|
||||
o usuário não tem tenant. Gratuito não gera intenção.
|
||||
- processar_pos_signup() (aceite legal + cria a intenção SÓ pro caminho pago).
|
||||
3. ⚠️ PEGADINHA CRÍTICA #2 (segurança): após o signUp, se NÃO veio sessão
|
||||
(confirmação pendente), ENCERRE qualquer sessão local (signOut scope:'local')
|
||||
e mostre uma tela "confirme seu e-mail". Senão, uma sessão anterior (ex: dev
|
||||
testando) vaza e o push pra /login joga o usuário pro painel da sessão antiga.
|
||||
A pessoa só pode logar APÓS clicar no link do e-mail.
|
||||
4. ⚠️ PEGADINHA CRÍTICA #3 (blindagem): um usuário logado SEM tenant nunca pode
|
||||
cair num painel quebrado. No guard, redirecione todo logado-sem-tenant (não-dev)
|
||||
pra uma tela /onboarding que resolve os estados: provisionando, slug colidiu
|
||||
(deixa escolher outro slug e finalizar — faça o auto_provision aceitar um
|
||||
p_slug_override), conta paga aguardando ativação, sem acesso, erro (retry).
|
||||
5. Signup coleta nome do sindicato + slug (sugestão + sanitização + disponibilidade
|
||||
ao vivo via RPC slug_disponivel que retorna {ok, motivo}) + "seu nome".
|
||||
Torne o plano gratuito visível na vitrine agora.
|
||||
6. E-mail de boas-vindas: edge function (Resend) que renderiza o template, disparada
|
||||
no provisionamento. Best-effort (não bloqueia o login). Destinatário derivado
|
||||
do JWT, não do body.
|
||||
|
||||
## SAAS / EXTRAS (faça os que fizerem sentido)
|
||||
- Página /saas/usuarios: 1 linha por tenant com o DONO (master) — nome, slug,
|
||||
e-mail principal — via uma RPC dev-only que cruza tenants+profiles+subscriptions
|
||||
(SECURITY DEFINER). Realce em verde + selo "Novo" pra cliente criado nas últimas
|
||||
24h (rowClass baseado em created_at). Reaproveite essa RPC pra mostrar o e-mail
|
||||
principal também nas listagens de assinaturas e tenants.
|
||||
- Notificação aos devs quando nasce/muda uma assinatura (incl. trial): trigger em
|
||||
subscriptions chamando a função notify_all_devs com deeplink. ⚠️ PEGADINHA #4:
|
||||
se o sino de notificações é um singleton com flag "initialized", garanta que ele
|
||||
RE-BUSCA ao trocar de usuário (logout+login), senão fica stale e ainda vaza
|
||||
notificações entre usuários. A notificação só aparece pós-provisionamento e no
|
||||
sino do DEV (não do novo usuário).
|
||||
- "Esqueci meu e-mail": tela onde a pessoa informa o IDENTIFICADOR do sindicato
|
||||
(slug, que ela escolheu e foi avisada ser definitivo) → o servidor acha o e-mail
|
||||
do dono → mostra só uma DICA MASCARADA (jo****@gm****.com) → envia magic link
|
||||
(signInWithOtp, que usa o mesmo pipeline de e-mail do GoTrue, sem depender de
|
||||
Resend) → a pessoa clica e entra. O e-mail real NUNCA volta pro cliente.
|
||||
- root_redirect: coluna em config + RPC pública + guard, pra escolher pra onde o
|
||||
visitante não logado vai na raiz "/" (landing ou login).
|
||||
- Lista de bloqueio (blacklist) de e-mails e slugs, gerida em Configurações:
|
||||
tabela blacklist (kind email|slug). E-mail bloqueia o cadastro DE VERDADE via
|
||||
trigger BEFORE INSERT em auth.users (não só no front); suporte a domínio inteiro
|
||||
com entrada '@dominio.com'. Slug integra no slug_disponivel (motivo 'bloqueado').
|
||||
|
||||
## MÉTODO DE TRABALHO
|
||||
- Tudo numa branch nova. Commits pequenos por assunto, mensagem clara.
|
||||
- Cada migration: aplique no banco local e TESTE em transação com ROLLBACK (crie
|
||||
auth.users fake + impersone via set_config('request.jwt.claims',...)) antes de
|
||||
seguir. RPCs idempotentes.
|
||||
- Rode o build do frontend a cada bloco pra pegar erro cedo.
|
||||
- NUMERE as migrations com cuidado pra não colidir versão (quebra o db push).
|
||||
- Me mostre o mapa da Fase 0 e as decisões da Fase 0.5 ANTES de codar.
|
||||
|
||||
## DEPLOY (no fim)
|
||||
Migrations no hosted (db push) → dashboard Auth "Confirm email" ON + Site/Redirect
|
||||
URLs corretas → deploy das edge functions + secret do provedor de e-mail → rebuild
|
||||
do frontend → smoke test do fluxo: /lp → grátis → confirma e-mail → entra
|
||||
provisionado → limite bloqueia → sino do dev → esqueci-email.
|
||||
|
||||
---
|
||||
Esse prompt é "diretor": ele força a IA a mapear o teu outro sistema primeiro (porque as tabelas/nomes vão diferir) e
|
||||
te apresentar decisões antes de codar — do jeito que fizemos aqui. As 4 pegadinhas marcadas com ⚠️ são as que mais
|
||||
custaram tempo; com elas escritas, a IA evita de cara.
|
||||
|
||||
Quer que eu gere também uma versão curta (1 parágrafo) pra um primeiro disparo, ou uma variante específica caso o
|
||||
outro sistema seja RLS puro (sem schema-per-tenant)? Aí eu ajusto os trechos de provisionamento/enforcement.
|
||||
@@ -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;
|
||||
|
||||
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)
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -523,6 +548,7 @@ async function logout() {
|
||||
tenant.reset();
|
||||
ent.invalidate();
|
||||
tf.invalidate();
|
||||
notificationStore.reset(); // pegadinha #4: limpa sino ao trocar de usuário
|
||||
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
@@ -556,7 +582,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 +637,9 @@ onMounted(async () => {
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="rail-topbar__actions">
|
||||
<!-- Upgrade PRO — só 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 +762,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;
|
||||
}
|
||||
|
||||
@@ -60,9 +60,11 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
{
|
||||
label: 'Operações',
|
||||
items: [
|
||||
{ label: 'Usuários / Donos', icon: 'pi pi-fw pi-id-card', to: '/saas/usuarios' },
|
||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
|
||||
{ label: 'Recursos por Clínica', icon: 'pi pi-fw pi-key', to: '/saas/tenant-features' },
|
||||
{ label: 'Segurança / Bots', icon: 'pi pi-fw pi-shield', to: '/saas/security' },
|
||||
{ label: 'Config / Bloqueios', icon: 'pi pi-fw pi-cog', to: '/saas/app-config' },
|
||||
{ label: 'Feriados', icon: 'pi pi-fw pi-star', to: '/saas/feriados' },
|
||||
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
|
||||
]
|
||||
|
||||
@@ -43,6 +43,23 @@ let sessionUidCache = null;
|
||||
let saasAdminCacheUid = null;
|
||||
let saasAdminCacheIsAdmin = null;
|
||||
|
||||
// Freemium F3c — cache do root_redirect (TTL 5min) pra não bater no DB a cada "/".
|
||||
let rootRedirectCache = null;
|
||||
let rootRedirectCacheAt = 0;
|
||||
async function getRootRedirectCached() {
|
||||
const TTL = 5 * 60 * 1000;
|
||||
if (rootRedirectCache && Date.now() - rootRedirectCacheAt < TTL) return rootRedirectCache;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_root_redirect');
|
||||
if (error) throw error;
|
||||
rootRedirectCache = data === 'login' ? 'login' : 'landing';
|
||||
rootRedirectCacheAt = Date.now();
|
||||
} catch {
|
||||
rootRedirectCache = 'landing'; // fallback seguro
|
||||
}
|
||||
return rootRedirectCache;
|
||||
}
|
||||
|
||||
// V#6 — cache de globalRole por uid com TTL.
|
||||
// Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role
|
||||
// mudasse durante a sessão. TTL de 5min força re-fetch periódico.
|
||||
@@ -323,6 +340,18 @@ export function applyGuards(router) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ✅ Freemium F3c: raiz "/" do visitante NÃO-logado vai pra landing
|
||||
// (/lp) ou login conforme root_redirect (config saas). Logado segue
|
||||
// pro fluxo normal (HomeCards).
|
||||
if (to.path === '/') {
|
||||
await waitSessionIfRefreshing();
|
||||
if (!sessionUser.value?.id) {
|
||||
const target = await getRootRedirectCached();
|
||||
_perfEnd();
|
||||
return target === 'login' ? { path: '/auth/login' } : { path: '/lp' };
|
||||
}
|
||||
}
|
||||
|
||||
// se rota não exige auth, libera
|
||||
if (!to.meta?.requiresAuth) {
|
||||
_perfEnd();
|
||||
@@ -632,6 +661,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);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -166,6 +166,18 @@ export default {
|
||||
name: 'saas-desenvolvimento',
|
||||
component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'usuarios',
|
||||
name: 'saas-usuarios',
|
||||
component: () => import('@/views/pages/saas/SaasUsuariosPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'app-config',
|
||||
name: 'saas-app-config',
|
||||
component: () => import('@/views/pages/saas/SaasAppConfigPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -215,6 +215,15 @@ export const useNotificationStore = defineStore('notifications', {
|
||||
supabase.removeChannel(this._channel);
|
||||
this._channel = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ⚠️ Pegadinha #4: ao trocar de usuário (logout→login), o store é um
|
||||
// singleton Pinia — sem reset, items/_channel ficam stale e vazam
|
||||
// notificações entre usuários. Chamar no logout.
|
||||
reset() {
|
||||
this.unsubscribe();
|
||||
this.items = [];
|
||||
this.drawerOpen = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -45,6 +45,13 @@ const recoveryEmail = ref('');
|
||||
const loadingRecovery = ref(false);
|
||||
const recoverySent = ref(false);
|
||||
|
||||
// Freemium F3d: "esqueci meu e-mail" — recupera por slug (identificador)
|
||||
const openRecoverEmail = ref(false);
|
||||
const recoverSlug = ref('');
|
||||
const recoverHint = ref('');
|
||||
const recoverDone = ref(false);
|
||||
const loadingRecoverEmail = ref(false);
|
||||
|
||||
// carrossel
|
||||
const SLIDES_FALLBACK = [
|
||||
{
|
||||
@@ -293,6 +300,37 @@ async function sendRecoveryEmail() {
|
||||
}
|
||||
}
|
||||
|
||||
function openForgotEmail() {
|
||||
recoverSlug.value = '';
|
||||
recoverHint.value = '';
|
||||
recoverDone.value = false;
|
||||
openRecoverEmail.value = true;
|
||||
}
|
||||
|
||||
async function recoverEmailBySlug() {
|
||||
const slug = String(recoverSlug.value || '').trim().toLowerCase();
|
||||
if (slug.length < 3) {
|
||||
toast.add({ severity: 'warn', summary: 'Identificador', detail: 'Informe o identificador do seu ambiente.', life: 3000 });
|
||||
return;
|
||||
}
|
||||
loadingRecoverEmail.value = true;
|
||||
recoverDone.value = false;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('recover-access', { body: { slug } });
|
||||
if (error) throw error;
|
||||
if (data?.ok && data?.hint) {
|
||||
recoverHint.value = data.hint;
|
||||
recoverDone.value = true;
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Não encontrado', detail: 'Nenhum ambiente com esse identificador.', life: 4000 });
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao recuperar acesso.', life: 4500 });
|
||||
} finally {
|
||||
loadingRecoverEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCarouselSlides();
|
||||
|
||||
@@ -456,8 +494,11 @@ onBeforeUnmount(() => {
|
||||
<Checkbox v-model="checked" inputId="rememberme1" binary :disabled="loading || loadingRecovery" />
|
||||
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)] cursor-pointer select-none"> Lembrar e-mail </label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" class="text-sm font-medium text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors" :disabled="loading || loadingRecovery" @click="openForgotEmail">Esqueci meu e-mail</button>
|
||||
<button type="button" class="text-sm font-medium text-indigo-500 hover:text-indigo-600 transition-colors" :disabled="loading || loadingRecovery" @click="openForgot">Esqueceu a senha?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="authError" class="rounded-xl border border-red-200 bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 px-4 py-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
@@ -507,6 +548,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog: Esqueci meu e-mail (recupera por slug) -->
|
||||
<Dialog v-model:visible="openRecoverEmail" modal header="Esqueci meu e-mail" :draggable="false" :style="{ width: '28rem', maxWidth: '92vw' }">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Informe o <b>identificador</b> do seu ambiente (aquele que você escolheu no cadastro). Enviamos um link de acesso pro e-mail do dono.</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-semibold">Identificador</label>
|
||||
<InputText v-model="recoverSlug" class="w-full" placeholder="meu_consultorio" :disabled="loadingRecoverEmail" @keydown.enter.prevent="recoverEmailBySlug" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
|
||||
<Button label="Cancelar" severity="secondary" outlined :disabled="loadingRecoverEmail" @click="openRecoverEmail = false" />
|
||||
<Button label="Enviar link de acesso" icon="pi pi-envelope" :loading="loadingRecoverEmail" @click="recoverEmailBySlug" />
|
||||
</div>
|
||||
|
||||
<div v-if="recoverDone" class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check mr-2 text-emerald-500" />
|
||||
Enviamos um link de acesso para <b>{{ recoverHint }}</b>. Abra o e-mail e clique no link pra entrar.
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — OnboardingPage (Freemium F2)
|
||||
|--------------------------------------------------------------------------
|
||||
| Tela do 1º login pós-confirmação. Provisiona o tenant gratuito via
|
||||
| auto_provision_free_tenant (lê 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); }
|
||||
|
||||
// welcome email — só no provisionamento NOVO, fire-and-forget (não bloqueia)
|
||||
if (data?.status === 'provisioned') {
|
||||
supabase.functions.invoke('send-welcome-email').catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
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 só 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 já 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>
|
||||
@@ -80,6 +80,14 @@ function priceFor(p) {
|
||||
return cents;
|
||||
}
|
||||
|
||||
// plano gratuito: por chave (_free) ou preço zero/ausente
|
||||
function isFreePlan(p) {
|
||||
const k = String(p?.plan_key || '').toLowerCase();
|
||||
if (k.endsWith('_free') || k === 'free') return true;
|
||||
const cents = priceFor(p);
|
||||
return cents == null || Number(cents) === 0;
|
||||
}
|
||||
|
||||
async function fetchPricing() {
|
||||
loadingPricing.value = true;
|
||||
try {
|
||||
@@ -481,11 +489,16 @@ onMounted(fetchPricing);
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-3xl font-semibold leading-none">
|
||||
<template v-if="isFreePlan(p)">
|
||||
Grátis<span class="text-sm font-normal text-[var(--text-color-secondary)]"> para sempre </span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatBRLFromCents(priceFor(p)) }}
|
||||
<span class="text-sm font-normal text-[var(--text-color-secondary)]"> /{{ billingInterval === 'month' ? 'mês' : 'ano' }} </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||
<div v-if="!isFreePlan(p) && billingInterval === 'year'" class="text-xs text-emerald-500 mt-1 font-medium">Melhor custo-benefício</div>
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--text-color-secondary)] min-h-[44px] leading-relaxed">
|
||||
{{ p.public_description || '—' }}
|
||||
|
||||
@@ -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);
|
||||
// ⚠️ 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;
|
||||
}
|
||||
|
||||
// ✅ 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
Já 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 dá 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,9 +554,9 @@ 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"> Já tenho conta — entrar </a>
|
||||
@@ -487,6 +566,7 @@ async function onSignup() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-xs text-[var(--text-color-secondary)] mt-4">Agência PSI — gestão clínica sem ruído.</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaasAppConfigPage (Freemium F3c/F3a)
|
||||
|--------------------------------------------------------------------------
|
||||
| Configurações globais do SaaS (dev-only):
|
||||
| • root_redirect: pra onde o visitante não-logado vai na raiz "/".
|
||||
| • Blacklist de e-mails (bloqueia cadastro; suporta '@dominio.com') e slugs.
|
||||
| RLS garante que só saas_admin lê/escreve (tabelas saas_app_config / blacklist).
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// ── root_redirect ──────────────────────────────────────────
|
||||
const rootRedirect = ref('landing');
|
||||
const savingRedirect = ref(false);
|
||||
const redirectOptions = [
|
||||
{ label: 'Landing (/lp)', value: 'landing' },
|
||||
{ label: 'Login', value: 'login' }
|
||||
];
|
||||
|
||||
async function loadRootRedirect() {
|
||||
const { data } = await supabase.from('saas_app_config').select('root_redirect').eq('id', true).maybeSingle();
|
||||
rootRedirect.value = data?.root_redirect || 'landing';
|
||||
}
|
||||
|
||||
async function saveRootRedirect(v) {
|
||||
savingRedirect.value = true;
|
||||
try {
|
||||
const { error } = await supabase.from('saas_app_config').update({ root_redirect: v, updated_at: new Date().toISOString() }).eq('id', true);
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Destino da raiz atualizado.', life: 2500 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4500 });
|
||||
await loadRootRedirect();
|
||||
} finally {
|
||||
savingRedirect.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── blacklist ──────────────────────────────────────────────
|
||||
const blacklist = ref([]);
|
||||
const loadingBl = ref(false);
|
||||
const newKind = ref('email');
|
||||
const newValue = ref('');
|
||||
const newNote = ref('');
|
||||
const kindOptions = [
|
||||
{ label: 'E-mail', value: 'email' },
|
||||
{ label: 'Slug', value: 'slug' }
|
||||
];
|
||||
|
||||
async function loadBlacklist() {
|
||||
loadingBl.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('blacklist').select('*').order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
blacklist.value = Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 });
|
||||
} finally {
|
||||
loadingBl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addEntry() {
|
||||
const value = String(newValue.value || '').trim().toLowerCase();
|
||||
if (!value) {
|
||||
toast.add({ severity: 'warn', summary: 'Valor', detail: 'Informe o e-mail/domínio ou slug.', life: 3000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { error } = await supabase.from('blacklist').insert({ kind: newKind.value, value, note: newNote.value?.trim() || null });
|
||||
if (error) throw error;
|
||||
newValue.value = '';
|
||||
newNote.value = '';
|
||||
await loadBlacklist();
|
||||
toast.add({ severity: 'success', summary: 'Adicionado', life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao adicionar.', life: 4500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEntry(row) {
|
||||
try {
|
||||
const { error } = await supabase.from('blacklist').delete().eq('id', row.id);
|
||||
if (error) throw error;
|
||||
await loadBlacklist();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao remover.', life: 4500 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRootRedirect();
|
||||
loadBlacklist();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Configurações do SaaS</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">Destino da raiz pública e listas de bloqueio.</p>
|
||||
</div>
|
||||
|
||||
<!-- root_redirect -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<h2 class="font-semibold mb-1">Raiz pública "/"</h2>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mb-3">Pra onde o visitante não-logado vai ao abrir a raiz do site.</p>
|
||||
<SelectButton v-model="rootRedirect" :options="redirectOptions" optionLabel="label" optionValue="value" :allowEmpty="false" :disabled="savingRedirect" @change="saveRootRedirect(rootRedirect)" />
|
||||
</div>
|
||||
|
||||
<!-- blacklist -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<h2 class="font-semibold mb-1">Lista de bloqueio</h2>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mb-3">E-mails bloqueiam o cadastro de verdade (use <code>@dominio.com</code> pra um domínio inteiro). Slugs ficam indisponíveis na criação.</p>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-2 mb-4">
|
||||
<SelectButton v-model="newKind" :options="kindOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
|
||||
<InputText v-model="newValue" :placeholder="newKind === 'email' ? 'spam@x.com ou @dominio.com' : 'slug_proibido'" class="w-64" @keydown.enter.prevent="addEntry" />
|
||||
<InputText v-model="newNote" placeholder="nota (opcional)" class="w-48" @keydown.enter.prevent="addEntry" />
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="addEntry" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="blacklist" :loading="loadingBl" paginator :rows="10" dataKey="id" size="small" stripedRows>
|
||||
<Column field="kind" header="Tipo">
|
||||
<template #body="{ data }"><Tag :severity="data.kind === 'email' ? 'warning' : 'info'" :value="data.kind" /></template>
|
||||
</Column>
|
||||
<Column field="value" header="Valor" />
|
||||
<Column field="note" header="Nota">
|
||||
<template #body="{ data }">{{ data.note || '—' }}</template>
|
||||
</Column>
|
||||
<Column header="" :style="{ width: '4rem' }">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded @click="removeEntry(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — SaasUsuariosPage (Freemium F3b)
|
||||
|--------------------------------------------------------------------------
|
||||
| 1 linha por tenant com o DONO (master): nome, slug, e-mail principal, plano.
|
||||
| Realce verde + selo "Novo" pra clientes criados nas últimas 24h. Dev-only
|
||||
| (RPC saas_list_account_owners é gated por is_saas_admin).
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import InputText from 'primevue/inputtext';
|
||||
|
||||
const toast = useToast();
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const filtro = ref('');
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = filtro.value.trim().toLowerCase();
|
||||
if (!q) return rows.value;
|
||||
return rows.value.filter((r) =>
|
||||
[r.tenant_name, r.slug, r.owner_name, r.owner_email, r.plan_key]
|
||||
.some((v) => String(v || '').toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const novos24h = computed(() => rows.value.filter((r) => r.is_new).length);
|
||||
|
||||
function planSeverity(plan) {
|
||||
const p = String(plan || '').toLowerCase();
|
||||
if (!p) return 'secondary';
|
||||
return p.endsWith('_free') ? 'info' : 'success';
|
||||
}
|
||||
|
||||
function rowClass(data) {
|
||||
return data?.is_new ? 'row-novo' : '';
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '—';
|
||||
try { return new Date(d).toLocaleString('pt-BR'); } catch { return d; }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('saas_list_account_owners');
|
||||
if (error) throw error;
|
||||
rows.value = Array.isArray(data) ? data : [];
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar.', life: 5000 });
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Usuários / Donos</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">Um por cliente (tenant), com o dono e o plano ativo.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag v-if="novos24h" severity="success" :value="`${novos24h} novo${novos24h === 1 ? '' : 's'} (24h)`" />
|
||||
<span class="p-input-icon-left">
|
||||
<InputText v-model="filtro" placeholder="Buscar nome, slug, e-mail…" class="w-72" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="filtered" :loading="loading" :rowClass="rowClass" paginator :rows="20" dataKey="tenant_id" stripedRows size="small" responsiveLayout="scroll">
|
||||
<Column field="tenant_name" header="Cliente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ data.tenant_name || '—' }}</span>
|
||||
<Tag v-if="data.is_new" severity="success" value="Novo" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="slug" header="Identificador" sortable />
|
||||
<Column field="owner_name" header="Dono" sortable>
|
||||
<template #body="{ data }">{{ data.owner_name || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="owner_email" header="E-mail" sortable>
|
||||
<template #body="{ data }">{{ data.owner_email || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="plan_key" header="Plano" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :severity="planSeverity(data.plan_key)" :value="data.plan_key || 'sem plano'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="Criado em" sortable>
|
||||
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.row-novo) {
|
||||
background: color-mix(in srgb, var(--p-green-500), transparent 88%) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Edge Function: recover-access (Freemium F3d)
|
||||
|--------------------------------------------------------------------------
|
||||
| "Esqueci meu e-mail": a pessoa informa o IDENTIFICADOR (slug) do seu
|
||||
| ambiente. O servidor acha o e-mail do dono, dispara um magic link
|
||||
| (signInWithOtp — mesmo pipeline de e-mail do GoTrue) e devolve só uma DICA
|
||||
| MASCARADA (jo****@gm****.com). O e-mail real NUNCA volta pro cliente.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// jo****@gm****.com
|
||||
function maskPart(s: string): string {
|
||||
if (!s) return '*'
|
||||
if (s.length <= 2) return s[0] + '***'
|
||||
return s.slice(0, 2) + '****'
|
||||
}
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = String(email).split('@')
|
||||
if (!domain) return maskPart(local)
|
||||
const dparts = domain.split('.')
|
||||
const dmasked = maskPart(dparts[0]) + (dparts.length > 1 ? '.' + dparts.slice(1).join('.') : '')
|
||||
return maskPart(local) + '@' + dmasked
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
|
||||
|
||||
try {
|
||||
const { slug } = await req.json().catch(() => ({}))
|
||||
const cleanSlug = String(slug || '').toLowerCase().trim()
|
||||
if (!cleanSlug || cleanSlug.length < 3) {
|
||||
return json({ ok: false, error: 'slug_required' }, 400)
|
||||
}
|
||||
|
||||
const admin = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// slug → tenant → dono (master) → user_id
|
||||
const { data: tenant } = await admin.from('tenants').select('id').eq('slug', cleanSlug).maybeSingle()
|
||||
if (!tenant) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
const { data: member } = await admin
|
||||
.from('tenant_members')
|
||||
.select('user_id')
|
||||
.eq('tenant_id', tenant.id)
|
||||
.eq('role', 'tenant_admin')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (!member?.user_id) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
const { data: userResp } = await admin.auth.admin.getUserById(member.user_id)
|
||||
const email = userResp?.user?.email
|
||||
if (!email) return json({ ok: false, error: 'not_found' })
|
||||
|
||||
// dispara o magic link via GoTrue (cliente anon — não cria usuário novo)
|
||||
const anon = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
)
|
||||
const { error: otpErr } = await anon.auth.signInWithOtp({
|
||||
email,
|
||||
options: { shouldCreateUser: false },
|
||||
})
|
||||
if (otpErr) {
|
||||
console.error('[recover-access] signInWithOtp error:', otpErr.message)
|
||||
// ainda devolve a dica — o e-mail existe; o envio pode reprocessar
|
||||
}
|
||||
|
||||
// só a DICA mascarada volta pro cliente
|
||||
return json({ ok: true, hint: maskEmail(email) })
|
||||
} catch (err) {
|
||||
console.error('[recover-access] fatal:', err)
|
||||
return json({ ok: false, error: 'internal' }, 500)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — Edge Function: send-welcome-email (Freemium F2)
|
||||
|--------------------------------------------------------------------------
|
||||
| E-mail de boas-vindas ao DONO de um tenant recém-provisionado. Best-effort:
|
||||
| nunca quebra o login/onboarding — se o SMTP falhar, só loga.
|
||||
|
|
||||
| • Destinatário derivado do JWT (não do body) — segurança.
|
||||
| • Usa um SMTP GLOBAL/de sistema (env), não o canal do tenant (um tenant
|
||||
| novo ainda não configurou notification_channels). Defaults = Mailpit local.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function welcomeHtml(name: string, tenantName: string, loginUrl: string): string {
|
||||
const ola = name ? `Olá, <strong>${name}</strong>!` : 'Olá!'
|
||||
return `
|
||||
<div style="font-family: system-ui, sans-serif; max-width: 520px; margin: 0 auto; color: #1f2937;">
|
||||
<p>${ola}</p>
|
||||
<p>Seu ambiente <strong>${tenantName}</strong> está pronto. Sua conta gratuita já foi ativada — é só entrar e começar.</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${loginUrl}" style="background:#10b981;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600;">Acessar meu ambiente →</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;">No plano gratuito você já tem o essencial. Quando precisar de mais, é só clicar em <strong>Upgrade PRO</strong> dentro do sistema.</p>
|
||||
<p style="color:#9ca3af;font-size:12px;margin-top:24px;">Agência PSI — gestão clínica sem ruído.</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization') || ''
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
|
||||
// cliente no contexto do usuário (resolve o JWT → destinatário)
|
||||
const userClient = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
})
|
||||
const { data: userData } = await userClient.auth.getUser()
|
||||
const user = userData?.user
|
||||
if (!user?.id || !user?.email) {
|
||||
return json({ ok: false, error: 'no_session' }, 401)
|
||||
}
|
||||
|
||||
const meta = (user.user_metadata || {}) as Record<string, string>
|
||||
|
||||
// nome do tenant: metadata OU consulta via admin
|
||||
const admin = createClient(supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||
let tenantName = String(meta.tenant_name || '').trim()
|
||||
let ownerName = String(meta.display_name || '').trim()
|
||||
if (!tenantName || !ownerName) {
|
||||
const { data: member } = await admin
|
||||
.from('tenant_members')
|
||||
.select('tenant_id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (member?.tenant_id) {
|
||||
const { data: t } = await admin.from('tenants').select('name').eq('id', member.tenant_id).maybeSingle()
|
||||
if (!tenantName) tenantName = t?.name || 'seu ambiente'
|
||||
}
|
||||
if (!ownerName) {
|
||||
const { data: pr } = await admin.from('profiles').select('full_name').eq('id', user.id).maybeSingle()
|
||||
ownerName = pr?.full_name || ''
|
||||
}
|
||||
}
|
||||
tenantName = tenantName || 'seu ambiente'
|
||||
|
||||
// SMTP global de sistema (env) — defaults = Mailpit local
|
||||
const host = Deno.env.get('SMTP_HOST') || 'mailpit'
|
||||
const port = parseInt(Deno.env.get('SMTP_PORT') || '1025', 10)
|
||||
const username = Deno.env.get('SMTP_USER') || 'test'
|
||||
const password = Deno.env.get('SMTP_PASS') || 'test'
|
||||
const fromEmail = Deno.env.get('SMTP_FROM') || 'no-reply@agenciapsi.local'
|
||||
const fromName = Deno.env.get('SMTP_FROM_NAME') || 'Agência PSI'
|
||||
const appUrl = (Deno.env.get('APP_URL') || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
const loginUrl = `${appUrl}/auth/login`
|
||||
|
||||
const subject = `Bem-vindo(a) — ${tenantName} está pronto`
|
||||
const html = welcomeHtml(ownerName, tenantName, loginUrl)
|
||||
const text = `${ownerName ? 'Olá, ' + ownerName + '!' : 'Olá!'}\n\nSeu ambiente ${tenantName} está pronto. Acesse: ${loginUrl}\n\nAgência PSI`
|
||||
|
||||
try {
|
||||
const client = new SmtpClient()
|
||||
const connectConfig = { hostname: host, port, username, password }
|
||||
if (port === 465) await client.connectTLS(connectConfig)
|
||||
else await client.connect(connectConfig)
|
||||
await client.send({
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: user.email,
|
||||
subject,
|
||||
content: text,
|
||||
html,
|
||||
})
|
||||
await client.close()
|
||||
} catch (smtpErr) {
|
||||
// best-effort: não quebra o onboarding
|
||||
console.error('[send-welcome-email] SMTP falhou (ignorado):', smtpErr)
|
||||
return json({ ok: false, error: 'smtp_failed' })
|
||||
}
|
||||
|
||||
return json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[send-welcome-email] fatal:', err)
|
||||
return json({ ok: false, error: 'internal' }, 500)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user