-- ============================================================================= -- Migration: 20260418000003_patient_invite_attempts_log -- Resolve A#24: log de tentativas de submit no cadastro público externo. -- ----------------------------------------------------------------------------- -- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do -- cliente não chega aqui (só o do connection pooler). Por isso o registro -- guarda o user_agent enviado pelo cliente (quando disponível) + metadados -- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge -- function no futuro (A#20). -- ============================================================================= CREATE TABLE IF NOT EXISTS public.patient_invite_attempts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), token text NOT NULL, ok boolean NOT NULL, error_code text, error_msg text, client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT) owner_id uuid, -- resolvido do token quando possível tenant_id uuid, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC); CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token); CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id); CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false; ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY; -- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links) DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts; CREATE POLICY patient_invite_attempts_owner_read ON public.patient_invite_attempts FOR SELECT TO authenticated USING (owner_id = auth.uid() OR public.is_saas_admin()); COMMENT ON TABLE public.patient_invite_attempts IS 'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.'; COMMENT ON COLUMN public.patient_invite_attempts.client_info IS 'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.'; -- ============================================================================= -- create_patient_intake_request_v2 — versão instrumentada -- ----------------------------------------------------------------------------- -- Mesma função do hardening anterior, agora com log em patient_invite_attempts. -- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo -- principal (log falhar jamais deve impedir o cadastro de ser aceito). -- ============================================================================= CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2( p_token text, p_payload jsonb, p_client_info text DEFAULT NULL ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_owner_id uuid; v_tenant_id uuid; v_active boolean; v_expires timestamptz; v_max_uses int; v_uses int; v_intake_id uuid; v_birth_raw text; v_birth date; v_email text; v_email_alt text; v_nome text; v_consent boolean; v_genero text; v_estado_civil text; v_err_msg text; v_err_code text; v_clean_info text; c_generos text[] := ARRAY['male','female','non_binary','other','na']; c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na']; -- Helper para logar: escreve em patient_invite_attempts e não propaga erros. -- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil. BEGIN -- Sanitiza client_info recebido (cap + trim) v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), ''); -- ─────────────────────────────────────────────────────────────────────── -- Resolve invite + valida TUDO (A#16) -- ─────────────────────────────────────────────────────────────────────── SELECT owner_id, tenant_id, active, expires_at, max_uses, uses INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses FROM public.patient_invites WHERE token = p_token LIMIT 1; IF v_owner_id IS NULL THEN v_err_code := 'TOKEN_INVALID'; v_err_msg := 'Token inválido'; -- Log + raise (owner_id NULL porque token não bateu) BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000'; END IF; IF v_active IS NOT TRUE THEN v_err_code := 'TOKEN_DISABLED'; v_err_msg := 'Link desativado'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000'; END IF; IF v_expires IS NOT NULL AND now() > v_expires THEN v_err_code := 'TOKEN_EXPIRED'; v_err_msg := 'Link expirado'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000'; END IF; IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN v_err_code := 'TOKEN_MAX_USES'; v_err_msg := 'Limite de uso atingido'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000'; END IF; -- Resolve tenant_id se invite não tiver (A#19) IF v_tenant_id IS NULL THEN SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_owner_id AND status = 'active' ORDER BY created_at ASC LIMIT 1; END IF; -- ─────────────────────────────────────────────────────────────────────── -- Sanitização + validações de campos (A#27) -- ─────────────────────────────────────────────────────────────────────── v_nome := nullif(trim(p_payload->>'nome_completo'), ''); IF v_nome IS NULL THEN v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; IF length(v_nome) > 200 THEN v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; v_email := nullif(lower(trim(p_payload->>'email_principal')), ''); IF v_email IS NULL THEN v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; IF length(v_email) > 120 THEN v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), ''); IF v_email_alt IS NOT NULL THEN IF length(v_email_alt) > 120 THEN v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; END IF; v_consent := coalesce((p_payload->>'consent')::boolean, false); IF v_consent IS NOT TRUE THEN v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório'; BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RAISE EXCEPTION '%', v_err_msg; END IF; v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), ''); v_birth := CASE WHEN v_birth_raw IS NULL THEN NULL WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY') ELSE NULL END; IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN v_birth := NULL; END IF; v_genero := nullif(trim(p_payload->>'genero'), ''); IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN v_genero := NULL; END IF; v_estado_civil := nullif(trim(p_payload->>'estado_civil'), ''); IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN v_estado_civil := NULL; END IF; -- ─────────────────────────────────────────────────────────────────────── -- INSERT -- ─────────────────────────────────────────────────────────────────────── INSERT INTO public.patient_intake_requests ( owner_id, tenant_id, token, status, consent, nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo, avatar_url, data_nascimento, cpf, rg, genero, estado_civil, profissao, escolaridade, nacionalidade, naturalidade, cep, pais, cidade, estado, endereco, numero, complemento, bairro, observacoes, encaminhado_por, onde_nos_conheceu ) VALUES ( v_owner_id, v_tenant_id, p_token, 'new', v_consent, v_nome, v_email, v_email_alt, nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''), nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'avatar_url'), ''), 500), v_birth, nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'rg'), ''), 20), v_genero, v_estado_civil, left(nullif(trim(p_payload->>'profissao'), ''), 120), left(nullif(trim(p_payload->>'escolaridade'), ''), 120), left(nullif(trim(p_payload->>'nacionalidade'), ''), 80), left(nullif(trim(p_payload->>'naturalidade'), ''), 120), nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'pais'), ''), 60), left(nullif(trim(p_payload->>'cidade'), ''), 120), left(nullif(trim(p_payload->>'estado'), ''), 2), left(nullif(trim(p_payload->>'endereco'), ''), 200), left(nullif(trim(p_payload->>'numero'), ''), 20), left(nullif(trim(p_payload->>'complemento'), ''), 120), left(nullif(trim(p_payload->>'bairro'), ''), 120), left(nullif(trim(p_payload->>'observacoes'), ''), 2000), left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120), left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80) ) RETURNING id INTO v_intake_id; UPDATE public.patient_invites SET uses = uses + 1 WHERE token = p_token; -- Log de sucesso (best-effort, não propaga erro) BEGIN INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id) VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END; RETURN v_intake_id; END; $function$; COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS 'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';