-- ============================================================================= -- Migration: 20260419000008_saas_twilio_config -- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy. -- -- DECISÃO DE SEGURANÇA: -- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var -- da Edge Function. Painel apenas exibe se está configurado (best-effort). -- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB -- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB -- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB -- -- Edge function: lê primeiro do banco; cai pra env vars como fallback se row -- ainda não foi configurada (back-compat com deploys antigos). -- ============================================================================= CREATE TABLE IF NOT EXISTS public.saas_twilio_config ( id boolean PRIMARY KEY DEFAULT true, account_sid text, whatsapp_webhook_url text, usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5, margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4, notes text, updated_at timestamptz NOT NULL DEFAULT now(), updated_by uuid, CONSTRAINT saas_twilio_config_singleton CHECK (id = true), CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100), CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10), CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'), CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://') ); INSERT INTO public.saas_twilio_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING; ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated; GRANT SELECT ON public.saas_twilio_config TO authenticated; DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config; CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config FOR SELECT TO authenticated USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional) COMMENT ON TABLE public.saas_twilio_config IS 'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.'; -- ───────────────────────────────────────────────────────────────────────── -- RPC get_twilio_config — retorna config atual (saas_admin OU service_role) -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.get_twilio_config() RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE cfg saas_twilio_config%ROWTYPE; BEGIN -- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function) -- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT) IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501'; END IF; SELECT * INTO cfg FROM saas_twilio_config WHERE id = true; IF NOT FOUND THEN RETURN jsonb_build_object( 'account_sid', NULL, 'whatsapp_webhook_url', NULL, 'usd_brl_rate', 5.5, 'margin_multiplier', 1.4 ); END IF; RETURN jsonb_build_object( 'account_sid', cfg.account_sid, 'whatsapp_webhook_url', cfg.whatsapp_webhook_url, 'usd_brl_rate', cfg.usd_brl_rate, 'margin_multiplier', cfg.margin_multiplier, 'notes', cfg.notes, 'updated_at', cfg.updated_at, 'updated_by', cfg.updated_by ); END; $function$; REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role; -- ───────────────────────────────────────────────────────────────────────── -- RPC update_twilio_config — só saas_admin -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.update_twilio_config( p_account_sid text DEFAULT NULL, p_whatsapp_webhook_url text DEFAULT NULL, p_usd_brl_rate numeric DEFAULT NULL, p_margin_multiplier numeric DEFAULT NULL, p_notes text DEFAULT NULL ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_caller uuid := auth.uid(); v_account_sid text; v_webhook_url text; v_notes text; BEGIN IF v_caller IS NULL THEN RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000'; END IF; IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501'; END IF; -- Sanitização v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), ''); v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), ''); v_notes := nullif(btrim(coalesce(p_notes, '')), ''); IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023'; END IF; IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023'; END IF; IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023'; END IF; IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023'; END IF; IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN v_notes := substring(v_notes FROM 1 FOR 1000); END IF; UPDATE saas_twilio_config SET account_sid = COALESCE(v_account_sid, account_sid), whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url), usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate), margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier), notes = COALESCE(v_notes, notes), updated_at = now(), updated_by = v_caller WHERE id = true; RETURN public.get_twilio_config(); END; $function$; REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;