-- ============================================================================= -- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam -- -- ⚠️ APLICAR COMO supabase_admin. -- -- Visitante anon não está logado → cada RPC resolve o tenant a partir do -- token/slug do registro que VIVE em public (F1b: document_share_links, -- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path -- pro schema só pras tabelas tenant (documents, document_signatures, -- document_access_logs, patients, agenda_*, recurrence_*). -- Tabelas que ficam em public seguem qualificadas (document_share_links, -- agendador_configuracoes/solicitacoes). -- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb. -- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out. -- ============================================================================= BEGIN; -- ── Documentos: tenant via document_share_links.tenant_id (public) ────────── CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema 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 public.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; v_schema := public.tenant_schema_for(sl.tenant_id); IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF; UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); BEGIN INSERT INTO document_access_logs (documento_id, action, share_link_id) VALUES (sl.documento_id, 'shared_link_access', sl.id); EXCEPTION WHEN OTHERS THEN NULL; END; SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id; RETURN jsonb_build_object('document_id', sl.documento_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 $$; CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text; BEGIN IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF; SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1; IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF; v_schema := public.tenant_schema_for(v_link.tenant_id); IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1; IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF; SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome, 'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs FROM document_signatures s WHERE s.documento_id = v_doc.id; RETURN jsonb_build_object('valid', true, 'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type, 'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento), 'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos); END $$; DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text); CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text; BEGIN IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF; SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1; IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF; v_schema := public.tenant_schema_for(v_link.tenant_id); IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); IF p_signature_id IS NOT NULL THEN SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1; ELSE SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1; END IF; IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF; v_ip := inet_client_addr(); BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END; UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(), hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now() WHERE id = v_sig.id RETURNING * INTO v_sig; UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id; RETURN to_jsonb(v_sig); END $$; -- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via -- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do -- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for -- o signatário (signatario_id = uid OU email do uid). DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text); DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text); CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text; BEGIN IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF; v_uid := auth.uid(); IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF; SELECT email INTO v_email FROM auth.users WHERE id = v_uid; PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); v_ip := inet_client_addr(); BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END; UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(), hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now() WHERE id = p_signature_id AND status IN ('pendente','enviado') AND (signatario_id = v_uid OR signatario_email = v_email OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid)) RETURNING * INTO v_row; IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF; RETURN to_jsonb(v_row); END $$; -- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop). -- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema). CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL) RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_uid uuid; t record; BEGIN v_uid := auth.uid(); IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF; FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP RETURN QUERY EXECUTE format( 'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, ' || 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em ' || 'FROM %1$I.document_signatures s ' || 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL ' || 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true ' || 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) ' || 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) ' || 'AND ($3::text[] IS NULL OR s.status = ANY($3))', t.schema_name) USING v_uid, t.tenant_id, p_status; END LOOP; END $$; -- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ────────── CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text) RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_normalized text; v_patient_id uuid; BEGIN v_normalized := public.normalize_phone_br(p_phone); IF v_normalized IS NULL OR length(v_normalized) < 10 THEN RETURN NULL; END IF; PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone) = v_normalized LIMIT 1; IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF; SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_alternativo) = v_normalized LIMIT 1; IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF; SELECT id INTO v_patient_id FROM patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1; RETURN v_patient_id; END $$; -- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─ CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz; v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean; BEGIN SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1; IF v_owner_id IS NULL THEN RETURN; END IF; v_schema := public.tenant_schema_for(v_tenant_id); IF v_schema IS NULL THEN RETURN; END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1); v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio; WHILE v_data <= v_data_fim LOOP v_db_dow := extract(dow from v_data::timestamp)::int; SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado; IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF; SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot; IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF; v_data := v_data + 1; END LOOP; END $$; CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' AS $$ DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz; v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean; v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text; BEGIN SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1; IF v_owner_id IS NULL THEN RETURN; END IF; v_schema := public.tenant_schema_for(v_tenant_id); IF v_schema IS NULL THEN RETURN; END IF; PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int; IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF; FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false; v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo'; IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF; IF NOT v_ocupado THEN SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_iniciov_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado; END IF; IF NOT v_ocupado THEN SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::timev_slot) INTO v_ocupado; END IF; IF NOT v_ocupado THEN FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::timev_slot LOOP v_rule_start_dow := extract(dow from v_rule.start_date)::int; v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int; v_day_diff := (p_data - v_first_occ)::int; IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN v_ex_type := NULL; SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1; IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF; END IF; END LOOP; END IF; IF NOT v_ocupado THEN SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::timev_slot) INTO v_ocupado; END IF; IF NOT v_ocupado THEN -- agendador_solicitacoes FICA em public (F1b) SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado; END IF; hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT; END LOOP; END $$; -- match_patient_by_phone só pra service_role (edge) REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role; COMMIT;