diff --git a/database-novo/manual/f6_2f_anon_token_rpcs.supabase_admin.sql b/database-novo/manual/f6_2f_anon_token_rpcs.supabase_admin.sql new file mode 100644 index 0000000..4090b02 --- /dev/null +++ b/database-novo/manual/f6_2f_anon_token_rpcs.supabase_admin.sql @@ -0,0 +1,247 @@ +-- ============================================================================= +-- 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; diff --git a/src/features/documents/composables/useDocumentSignatures.js b/src/features/documents/composables/useDocumentSignatures.js index 0e6a96a..45dcc5c 100644 --- a/src/features/documents/composables/useDocumentSignatures.js +++ b/src/features/documents/composables/useDocumentSignatures.js @@ -66,12 +66,17 @@ export function useDocumentSignatures() { } } - async function sign(signatureId, { hashDocumento = null } = {}) { + async function sign(signatureId, { hashDocumento = null, tenantId = null } = {}) { loading.value = true; error.value = ''; try { - const updated = await signByPortal(signatureId, hashDocumento); - const idx = signatures.value.findIndex(s => s.id === signatureId); + // schema-per-tenant: a assinatura vive no schema do tenant. Resolve o + // tenant_id da própria linha (list_my_signatures retorna tenant_id) ou + // do parâmetro explícito. + const row = signatures.value.find(s => (s.signature_id ?? s.id) === signatureId); + const tid = tenantId || row?.tenant_id; + const updated = await signByPortal(tid, signatureId, hashDocumento); + const idx = signatures.value.findIndex(s => (s.signature_id ?? s.id) === signatureId); if (idx >= 0) signatures.value.splice(idx, 1, updated); return updated; } catch (e) { diff --git a/src/services/DocumentSignatures.service.js b/src/services/DocumentSignatures.service.js index 365b4f5..8be840b 100644 --- a/src/services/DocumentSignatures.service.js +++ b/src/services/DocumentSignatures.service.js @@ -174,10 +174,12 @@ export async function refuseSignature(signatureId) { // só passa o hash SHA-256 do PDF (gerado via hashDocument()) pra // garantir integridade do documento no momento da assinatura. // -export async function signByPortal(signatureId, hashDocumento = null) { +export async function signByPortal(tenantId, signatureId, hashDocumento = null) { if (!signatureId) throw new Error('ID da assinatura inválido.'); + if (!tenantId) throw new Error('Tenant da assinatura não resolvido.'); const { data, error } = await supabase.rpc('sign_document_by_signature_id', { + p_tenant_id: tenantId, p_signature_id: signatureId, p_hash_documento: hashDocumento || null });