freemium F1: enforcement de limite de pacientes (schema-per-tenant)

- therapist_free ganha max_patients=20 (clinic_free ja tinha 30)
- trigger BEFORE INSERT em patients le plan_features.limits em runtime,
  resolve tenant por TG_TABLE_SCHEMA, plano ativo (clinica via tenant_id +
  pessoal via owner user_id), conta vivos (status<>Arquivado) e da RAISE
  PLAN_LIMIT_REACHED|patients|<n>
- helpers tenant_active_plan_id / plan_feature_limit (globais, sobrevivem F6.3)
- wiring: tenants novos ganham via trg_attach_business_triggers; 9 existentes backfill
- testado: clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado (rollback)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 18:05:19 -03:00
parent 98fe183bac
commit a73b82fa86
6 changed files with 376 additions and 338 deletions
@@ -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,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;