-- ============================================================================= -- 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;