Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260417000001_dev_tables
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
-- -----------------------------------------------------------------------------
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
-- saas_admins via RLS (helper public.is_saas_admin()).
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Helper trigger: updated_at
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- =============================================================================
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
id BIGSERIAL PRIMARY KEY,
numero INTEGER NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
objetivo TEXT,
timeline_sugerida VARCHAR(160),
criterio_saida TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
data_inicio DATE,
data_fim DATE,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
BEFORE UPDATE ON public.dev_roadmap_phases
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 2. dev_roadmap_items — Itens das fases
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
id BIGSERIAL PRIMARY KEY,
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
numero INTEGER,
bloco VARCHAR(160),
feature TEXT NOT NULL,
descricao TEXT,
esforco VARCHAR(4)
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
prioridade VARCHAR(20)
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
notas TEXT,
assignee VARCHAR(120),
data_inicio DATE,
data_conclusao DATE,
ordem INTEGER NOT NULL DEFAULT 0,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
CREATE TRIGGER trg_dev_roadmap_items_updated_at
BEFORE UPDATE ON public.dev_roadmap_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
id BIGSERIAL PRIMARY KEY,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao_problema TEXT,
solucao TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
resolvido_em DATE,
sessao_resolucao VARCHAR(160),
arquivo_afetado TEXT,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
CREATE TRIGGER trg_dev_auditoria_items_updated_at
BEFORE UPDATE ON public.dev_auditoria_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 4. dev_competitors — Concorrentes
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitors (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(80) NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
pais VARCHAR(40),
foco VARCHAR(160),
pricing TEXT,
posicionamento TEXT,
url TEXT,
ultima_pesquisa DATE,
notas TEXT,
ativo BOOLEAN NOT NULL DEFAULT true,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
CREATE TRIGGER trg_dev_competitors_updated_at
BEFORE UPDATE ON public.dev_competitors
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 5. dev_competitor_features — features de cada concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
id BIGSERIAL PRIMARY KEY,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
categoria VARCHAR(120),
nome TEXT NOT NULL,
descricao TEXT,
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
fonte_url TEXT,
data_fonte DATE,
destaque BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
CREATE TRIGGER trg_dev_competitor_features_updated_at
BEFORE UPDATE ON public.dev_competitor_features
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
id BIGSERIAL PRIMARY KEY,
dominio VARCHAR(120),
feature TEXT NOT NULL,
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
nossa_nota TEXT,
importancia VARCHAR(20)
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
BEFORE UPDATE ON public.dev_comparison_matrix
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
id BIGSERIAL PRIMARY KEY,
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
nota TEXT,
fonte VARCHAR(20)
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (comparison_id, competitor_id)
);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
CREATE TRIGGER trg_dev_ccs_updated_at
BEFORE UPDATE ON public.dev_comparison_competitor_status
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
id BIGSERIAL PRIMARY KEY,
tipo VARCHAR(40) NOT NULL,
comando TEXT,
sucesso BOOLEAN NOT NULL DEFAULT false,
stdout TEXT,
stderr TEXT,
duration_ms INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
trigger_user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
-- =============================================================================
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
-- =============================================================================
DO $$
DECLARE
t TEXT;
dev_tables TEXT[] := ARRAY[
'dev_roadmap_phases',
'dev_roadmap_items',
'dev_auditoria_items',
'dev_competitors',
'dev_competitor_features',
'dev_comparison_matrix',
'dev_comparison_competitor_status',
'dev_generation_log'
];
BEGIN
FOREACH t IN ARRAY dev_tables
LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
-- Drop policy se existir (idempotente)
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
-- Cria policy que permite tudo pra saas_admin
EXECUTE format(
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());',
t || '_saas_admin_all',
t
);
END LOOP;
END $$;
-- =============================================================================
-- Comentários
-- =============================================================================
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
@@ -0,0 +1,48 @@
-- =============================================================================
-- Migration: 20260417000002_dev_tables_ordem
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
-- (pra suportar reordenação por drag-and-drop na UI).
-- =============================================================================
-- dev_auditoria_items
ALTER TABLE public.dev_auditoria_items
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
-- Popular ordem existente (status + id pra evitar colisão)
UPDATE public.dev_auditoria_items SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
ORDER BY
CASE status
WHEN 'aberto' THEN 1
WHEN 'em_analise' THEN 2
WHEN 'resolvido' THEN 3
WHEN 'wontfix' THEN 4
WHEN 'duplicado' THEN 5
ELSE 6
END,
id
) AS rn
FROM public.dev_auditoria_items
) sub
WHERE public.dev_auditoria_items.id = sub.id;
-- dev_competitor_features
ALTER TABLE public.dev_competitor_features
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
ON public.dev_competitor_features(competitor_id, ordem);
-- Popular ordem existente (por competitor + categoria + id)
UPDATE public.dev_competitor_features SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY competitor_id
ORDER BY COALESCE(categoria, 'zzz'), id
) AS rn
FROM public.dev_competitor_features
) sub
WHERE public.dev_competitor_features.id = sub.id;
@@ -0,0 +1,51 @@
-- =============================================================================
-- Migration: 20260418000001_dev_verificacoes
-- Nova aba "Verificações" em /saas/desenvolvimento
-- -----------------------------------------------------------------------------
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
-- olhar, o que foi encontrado em cada área do sistema.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao TEXT,
resultado TEXT,
acao_sugerida TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
verificado_em DATE,
sessao_verificacao VARCHAR(160),
arquivo_afetado TEXT,
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
CREATE TRIGGER trg_dev_verificacoes_updated_at
BEFORE UPDATE ON public.dev_verificacoes_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
@@ -0,0 +1,403 @@
-- =============================================================================
-- Migration: 20260418000002_patient_intake_security_hardening
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
-- -----------------------------------------------------------------------------
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
-- avatars + storage policies.
--
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
-- whitelist de valores, validação de token completa (active/expires/max_uses).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. create_patient_intake_request_v2 — versão hardened
-- -----------------------------------------------------------------------------
-- Mudanças vs versão anterior:
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
-- • A#27: length checks em TODOS os campos texto
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
-- lower em emails, whitelist para genero/estado_civil
-- • Consent obrigatório (raise se false)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb
)
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;
-- Whitelists para campos tipados
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Carrega invite e 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
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Resolver tenant_id (A#19)
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
-- ───────────────────────────────────────────────────────────────────────
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)
-- ───────────────────────────────────────────────────────────────────────
-- Nome obrigatório (max 200)
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
RAISE EXCEPTION 'Nome é obrigatório';
END IF;
IF length(v_nome) > 200 THEN
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
END IF;
-- Email principal obrigatório + lower + max 120
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
RAISE EXCEPTION 'E-mail é obrigatório';
END IF;
IF length(v_email) > 120 THEN
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail inválido';
END IF;
-- Email alternativo opcional mas validado se presente
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
RAISE EXCEPTION 'E-mail alternativo muito longo';
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail alternativo inválido';
END IF;
END IF;
-- Consent obrigatório
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
RAISE EXCEPTION 'Consentimento é obrigatório';
END IF;
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
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;
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
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 com sanitização inline
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
-- do terapeuta, não deve vir do paciente.
-- ───────────────────────────────────────────────────────────────────────
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;
-- Incrementa contador de uso (A#16)
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) 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 (campo interno); exige consent=true.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
-- -----------------------------------------------------------------------------
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
-- Novo: gera gen_random_uuid() server-side e retorna.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_new_token text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
v_new_token := replace(gen_random_uuid()::text, '-', '');
-- Resolve tenant_id do usuário (active)
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
-- Desativa tokens ativos anteriores
UPDATE public.patient_invites
SET active = false
WHERE owner_id = v_uid
AND active = true;
-- Insere novo
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_new_token, true);
RETURN v_new_token;
END;
$function$;
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
-- -----------------------------------------------------------------------------
-- Substitui o client-side newToken() + direct insert em patient_invites.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_token text;
v_existing text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
SELECT token
INTO v_existing
FROM public.patient_invites
WHERE owner_id = v_uid
AND active = true
ORDER BY created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
v_token := replace(gen_random_uuid()::text, '-', '');
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_token, true);
RETURN v_token;
END;
$function$;
COMMENT ON FUNCTION public.issue_patient_invite() IS
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
-- -----------------------------------------------------------------------------
UPDATE storage.buckets
SET file_size_limit = 5242880, -- 5 MB
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
WHERE id = 'avatars';
-- ─────────────────────────────────────────────────────────────────────────
-- 5. Storage policies — remover upload anon irrestrito (A#15)
-- -----------------------------------------------------------------------------
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
-- arquivo. Removemos essas policies. Upload público passa a exigir token
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
-- o terapeuta vê no painel de cadastros recebidos).
CREATE POLICY "intake_read_owner_only"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = 'intakes'
);
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
@@ -0,0 +1,280 @@
-- =============================================================================
-- 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).';
@@ -0,0 +1,149 @@
-- =============================================================================
-- Migration: 20260418000004_dev_tests
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
-- -----------------------------------------------------------------------------
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
-- áreas estão cobertas por teste?" sem rodar npm test.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_test_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120), -- unit, integration, e2e, manual
titulo TEXT NOT NULL,
arquivo TEXT,
descricao TEXT,
total_tests INTEGER DEFAULT 0,
passing INTEGER DEFAULT 0,
failing INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0,
cobertura_pct NUMERIC(5,2), -- cobertura estimada daquela área
status VARCHAR(20) NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
last_run_at TIMESTAMPTZ,
sessao_criacao VARCHAR(160),
notas TEXT,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
CREATE TRIGGER trg_dev_test_items_updated_at
BEFORE UPDATE ON public.dev_test_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_test_items IS
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
-- =============================================================================
-- Seed inicial — testes existentes em 2026-04-18
-- =============================================================================
INSERT INTO public.dev_test_items
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
VALUES
('agenda', 'unit',
'useRecurrence — geração de ocorrências',
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
23, 23, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
ARRAY['unit','agenda','recurrence','critical'], 1),
('agenda', 'unit',
'agendaMappers — transformação pra FullCalendar',
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
40, 40, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
ARRAY['unit','agenda','mappers'], 2),
('auth', 'a_escrever',
'guards.js — branches do router beforeEach',
'src/router/__tests__/guards.spec.js (não existe)',
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
ARRAY['unit','auth','router','guard','missing'], 3),
('auth', 'a_escrever',
'session.js — hydrate e race conditions',
'src/app/__tests__/session.spec.js (não existe)',
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
ARRAY['unit','auth','session','race','missing'], 4),
('stores', 'a_escrever',
'tenantStore — singleflight + persist',
'src/stores/__tests__/tenantStore.spec.js (não existe)',
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
ARRAY['unit','store','tenant','missing'], 5),
('utils', 'a_escrever',
'roleNormalizer — saídas esperadas',
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
$$Fácil de testar função pura, sem IO. Cobre: tenant_admin+therapisttherapist, tenant_admin+clinicclinic_admin, tenant_admin+supervisorsupervisor, tenant_admin sem kindclinic_admin, clinic_adminclinic_admin, pass-through.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
ARRAY['unit','utils','trivial'], 6),
('pacientes', 'a_escrever',
'Cadastros externos — fluxo do paciente',
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
ARRAY['unit','pacientes','external','security-regression'], 7),
('database', 'manual',
'RPCs de intake — validação de inputs maliciosos',
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
ARRAY['manual','sql','security','rpc'], 8),
('agenda', 'a_escrever',
'useAgendaEvents — wrapper do repository',
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 2 — agenda',
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
ARRAY['unit','agenda','composable','missing'], 9),
('e2e', 'a_escrever',
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
'(não existe)',
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta em /therapist/patients/recebidos.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
@@ -0,0 +1,167 @@
-- =============================================================================
-- Migration: 20260418000005_saas_rls_emergency_fix
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
-- subscription_intents_personal/tenant, plan_public, ...).
--
-- Estratégia:
-- 1. Habilitar RLS em todas as 7 tabelas
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
-- 4. Policies explícitas por caso de uso
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. REVOKE grants inseguros
-- -----------------------------------------------------------------------------
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
REVOKE ALL ON public.plan_public FROM anon, authenticated;
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
-- Concede o mínimo necessário (controlado por RLS abaixo)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
GRANT SELECT ON public.plan_public TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. HABILITAR RLS em todas
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. POLICIES — tenant_features
-- -----------------------------------------------------------------------------
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
CREATE POLICY tenant_features_select ON public.tenant_features
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
CREATE POLICY tenant_features_write ON public.tenant_features
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
CREATE POLICY plan_prices_read ON public.plan_prices
FOR SELECT TO authenticated
USING (true); -- preços são públicos pra usuários logados
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
CREATE POLICY plan_prices_write ON public.plan_prices
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
CREATE POLICY plan_public_read_anon ON public.plan_public
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
CREATE POLICY plan_public_write ON public.plan_public
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 6. POLICIES — subscription_intents_personal + _tenant
-- -----------------------------------------------------------------------------
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
FOR ALL TO authenticated
USING (user_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
-- -----------------------------------------------------------------------------
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
COMMENT ON TABLE public.tenant_features IS
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
@@ -0,0 +1,214 @@
-- =============================================================================
-- Migration: 20260419000001_tenant_features_b2_governance
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
--
-- Mudanças:
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
-- SECURITY DEFINER, com regras assimétricas:
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
-- Toda mudança grava em tenant_feature_exceptions_log.
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
-- Tenant_admin agora muda só via RPC.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. Trigger: bypass controlado por session flag
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_allowed boolean;
v_bypass text;
BEGIN
-- Só valida quando está habilitando
IF new.enabled IS DISTINCT FROM true THEN
RETURN new;
END IF;
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
-- após validar que o caller é saas_admin com reason.
v_bypass := current_setting('app.allow_feature_exception', true);
IF v_bypass = 'true' THEN
RETURN new;
END IF;
-- Permitido pelo plano do tenant?
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements_full v
WHERE v.tenant_id = new.tenant_id
AND v.feature_key = new.feature_key
AND v.allowed = true
) INTO v_allowed;
IF NOT v_allowed THEN
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
new.feature_key, new.tenant_id
USING ERRCODE = 'P0001';
END IF;
RETURN new;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. RPC set_tenant_feature_exception
-- (substitui versão anterior que retornava void; retorna jsonb agora)
-- -----------------------------------------------------------------------------
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
p_tenant_id uuid,
p_feature_key text,
p_enabled boolean,
p_reason text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_is_saas boolean := public.is_saas_admin();
v_is_tenant_adm boolean;
v_plan_allows boolean;
v_feature_key text;
v_reason text;
v_is_exception boolean;
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização (padrão V#31)
-- ───────────────────────────────────────────────────────────────────────
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
END IF;
IF p_enabled IS NULL THEN
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
END IF;
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
IF v_feature_key IS NULL THEN
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
END IF;
IF length(v_feature_key) > 80 THEN
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
END IF;
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
END IF;
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
v_reason := substring(v_reason FROM 1 FOR 500);
END IF;
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Plano permite essa feature?
-- ───────────────────────────────────────────────────────────────────────
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements vte
WHERE vte.tenant_id = p_tenant_id
AND vte.feature_key = v_feature_key
) INTO v_plan_allows;
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
-- ───────────────────────────────────────────────────────────────────────
-- Caller é tenant_admin desse tenant?
-- ───────────────────────────────────────────────────────────────────────
v_is_tenant_adm := EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = p_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
);
-- ───────────────────────────────────────────────────────────────────────
-- Autorização (assimétrica — V#34 Opção B2)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
-- Override positivo fora do plano = exceção comercial
IF NOT v_is_saas THEN
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
END IF;
IF v_reason IS NULL THEN
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
END IF;
ELSE
-- Demais casos: tenant_admin OR saas_admin
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
END IF;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Persistência: bypass controlado do trigger guard quando é exceção
-- (escopo de transação via SET LOCAL — só esta RPC vê)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'true', true);
END IF;
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
ON CONFLICT (tenant_id, feature_key)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'false', true);
END IF;
INSERT INTO public.tenant_feature_exceptions_log
(tenant_id, feature_key, enabled, reason, created_by)
VALUES
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
RETURN jsonb_build_object(
'tenant_id', p_tenant_id,
'feature_key', v_feature_key,
'enabled', p_enabled,
'plan_allows', v_plan_allows,
'is_exception', v_is_exception,
'reason', v_reason
);
END;
$function$;
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. Policy: writes diretos só via saas_admin
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
@@ -0,0 +1,21 @@
-- =============================================================================
-- Migration: 20260419000002_features_is_active
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
--
-- Estratégia conservadora:
-- - features.is_active boolean DEFAULT true NOT NULL
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
-- enquanto não houver migração explícita
-- - Permite reativar feature acidentalmente deprecada
-- =============================================================================
ALTER TABLE public.features
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE INDEX IF NOT EXISTS idx_features_is_active
ON public.features (is_active) WHERE is_active = false;
COMMENT ON COLUMN public.features.is_active IS
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
@@ -0,0 +1,69 @@
-- =============================================================================
-- Migration: 20260419000003_delete_plan_safe
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
--
-- Cria RPC delete_plan_safe(plan_id) que:
-- - Valida saas_admin
-- - Conta subscriptions ativas (status='active') no plano
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
p_plan_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_active_count int;
v_plan_key text;
BEGIN
IF auth.uid() 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 deletar planos' USING ERRCODE = '42501';
END IF;
IF p_plan_id IS NULL THEN
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
END IF;
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
IF v_plan_key IS NULL THEN
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
END IF;
SELECT COUNT(*) INTO v_active_count
FROM public.subscriptions
WHERE plan_id = p_plan_id
AND status = 'active';
IF v_active_count > 0 THEN
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
v_plan_key, v_active_count
USING ERRCODE = 'P0001';
END IF;
-- desativa preços ativos antes de deletar
UPDATE public.plan_prices
SET is_active = false,
active_to = now()
WHERE plan_id = p_plan_id
AND is_active = true;
DELETE FROM public.plans WHERE id = p_plan_id;
RETURN jsonb_build_object(
'deleted', true,
'plan_key', v_plan_key
);
END;
$function$;
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
@@ -0,0 +1,46 @@
-- =============================================================================
-- Migration: 20260419000004_consolidate_policies
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
-- subscriptions. Remove legado redundante e documenta as que ficam.
--
-- Análise (auditada via pg_policies):
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
-- • subscriptions:
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
-- é SUBSET de "subscriptions_read_own"
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
-- "subscriptions_update_only_saas_admin"
-- Removidas as 3 redundâncias.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Drops dos legados redundantes
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
-- ─────────────────────────────────────────────────────────────────────────
-- COMMENT ON POLICY — documenta escopo das que ficaram
-- -----------------------------------------------------------------------------
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
@@ -0,0 +1,29 @@
-- =============================================================================
-- Migration: 20260419000005_restrict_intake_rpc
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
--
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
-- proteção do front (Turnstile etc).
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
-- a RPC com service_role. Anon não chama mais a RPC direto.
-- =============================================================================
-- Revoga PUBLIC (DEFAULT) e anon
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
-- Mesma proteção para RPC v1 legada (caso ainda exista)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
) THEN
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
END IF;
END$$;
@@ -0,0 +1,136 @@
-- =============================================================================
-- Migration: 20260419000006_layered_bot_defense
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
--
-- Camadas:
-- 1. Honeypot field (no front) → invisível, sempre ativo
-- 2. Rate limit por IP no edge → submission_rate_limits
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
-- 4. Logging em public_submission_attempts (genérico, não só intake)
-- 5. Modo paranoid global → saas_security_config.captcha_required
--
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. saas_security_config (singleton)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.saas_security_config (
id boolean PRIMARY KEY DEFAULT true,
honeypot_enabled boolean NOT NULL DEFAULT true,
rate_limit_enabled boolean NOT NULL DEFAULT true,
rate_limit_window_min integer NOT NULL DEFAULT 10,
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
captcha_after_failures integer NOT NULL DEFAULT 3,
captcha_required_globally boolean NOT NULL DEFAULT false,
block_duration_min integer NOT NULL DEFAULT 30,
captcha_required_window_min integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK (id = true)
);
INSERT INTO public.saas_security_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
CREATE POLICY saas_security_config_read ON public.saas_security_config
FOR SELECT TO authenticated
USING (true); -- qualquer logado pode ler config global (não tem segredo)
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
CREATE POLICY saas_security_config_write ON public.saas_security_config
FOR UPDATE TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. public_submission_attempts (log genérico)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
user_agent text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
GRANT SELECT ON public.public_submission_attempts TO authenticated;
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
-- ─────────────────────────────────────────────────────────────────────────
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
fail_count integer NOT NULL DEFAULT 0,
window_start timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
requires_captcha_until timestamptz,
last_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (ip_hash, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
GRANT SELECT ON public.submission_rate_limits TO authenticated;
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
-- ─────────────────────────────────────────────────────────────────────────
-- 4. math_challenges (TTL 5min, limpa via cron)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.math_challenges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
question text NOT NULL,
answer integer NOT NULL,
used boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
);
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
@@ -0,0 +1,299 @@
-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
-- • record_submission_attempt — log + atualiza contadores e bloqueios
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
-- • verify_math_challenge — valida {id, answer}, marca used
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_ip_hash text,
p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$function$;
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
p_endpoint text,
p_ip_hash text,
p_success boolean,
p_blocked_by text DEFAULT NULL,
p_error_code text DEFAULT NULL,
p_error_msg text DEFAULT NULL,
p_user_agent text DEFAULT NULL,
p_metadata jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$function$;
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
p_id uuid,
p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$function$;
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$function$;
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
@@ -0,0 +1,155 @@
-- =============================================================================
-- 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;
@@ -0,0 +1,34 @@
-- =============================================================================
-- Migration: 20260419000009_patient_session_counts_rpc
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
--
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
-- consistente com a policy SELECT de agenda_eventos.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
p_patient_ids uuid[]
)
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
ae.patient_id,
COUNT(*)::int AS session_count,
MAX(ae.inicio_em) AS last_session_at
FROM public.agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
AND ae.tenant_id IN (
SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
)
GROUP BY ae.patient_id;
$function$;
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
@@ -0,0 +1,304 @@
-- =============================================================================
-- Migration: 20260419000010_documents_security_hardening
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
-- pra próxima sessão (precisam de design/decisão).
--
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
-- (storage.foldername(name))[1] = tenant_id
-- =============================================================================
-- Tabelas de documents são owned por supabase_admin
SET LOCAL ROLE supabase_admin;
-- ─────────────────────────────────────────────────────────────────────────
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
CREATE POLICY "documents: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "documents: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "documents: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'generated-docs'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#45: documents — policies separadas por cmd
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
DROP POLICY IF EXISTS "documents: select" ON public.documents;
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
DROP POLICY IF EXISTS "documents: update" ON public.documents;
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
-- SELECT: owner OR tenant_member ativo OR saas_admin
CREATE POLICY "documents: select" ON public.documents
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
CREATE POLICY "documents: insert" ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- UPDATE: só owner
CREATE POLICY "documents: update" ON public.documents
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
-- DELETE: só owner ou saas_admin
CREATE POLICY "documents: delete" ON public.documents
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token text;
BEGIN
v_token := nullif(btrim(coalesce(p_token, '')), '');
IF v_token IS NULL THEN
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
END IF;
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF sl.ativo IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- Incrementa uso atomicamente
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
-- Loga acesso (best-effort)
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
-- não derruba a request se log falhar (schema pode variar)
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_id,
'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes
);
END;
$function$;
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
CREATE POLICY "dsl: creator full access" ON public.document_share_links
FOR ALL TO authenticated
USING (criado_por = auth.uid() OR public.is_saas_admin())
WITH CHECK (criado_por = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
CREATE POLICY "ds: select" ON public.document_signatures
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
CREATE POLICY "ds: insert" ON public.document_signatures
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
AND (signatario_id IS NULL OR signatario_id = auth.uid())
);
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
CREATE POLICY "ds: update" ON public.document_signatures
FOR UPDATE TO authenticated
USING (signatario_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
-- DELETE: signatário, saas_admin ou tenant_admin/owner
CREATE POLICY "ds: delete" ON public.document_signatures
FOR DELETE TO authenticated
USING (
signatario_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#48: document_access_logs — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#49: document_templates — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
CREATE POLICY "dt: owner can insert" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (
is_global = false
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (is_global = true AND public.is_saas_admin());