diff --git a/database-novo/manual/freemium_f2_provisioning.supabase_admin.sql b/database-novo/manual/freemium_f2_provisioning.supabase_admin.sql new file mode 100644 index 0000000..599de24 --- /dev/null +++ b/database-novo/manual/freemium_f2_provisioning.supabase_admin.sql @@ -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;