freemium F2: RPCs idempotentes do self-service
- slug_disponivel(p_slug) -> {ok, motivo} (formato + reservados + uso),
chamavel por anon (signup acontece antes do login)
- auto_provision_free_tenant(p_slug_override): le raw_user_meta_data, cria
tenant (slug escolhido OU auto), vira master, clona schema, cria subscription
gratuita ativa (XOR clinic->tenant_id / therapist->user_id). Idempotente.
- processar_pos_signup(): cria intent SO pro caminho pago, na tabela real por
target (a view subscription_intents nao propaga user_id). Idempotente.
- testado em ROLLBACK: clinica + terapeuta, provision+idempotencia+intents OK
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
Reference in New Issue
Block a user