F6.2 Lote F: RPCs anon/token resolvem tenant por token/slug + roteiam

DB (supabase_admin, manual/f6_2f_anon_token_rpcs.supabase_admin.sql):
- Documentos anon (token): validate_share_token, get_signable_document_by_token,
  sign_document_by_token resolvem tenant de document_share_links.tenant_id
  (public/F1b) -> set_config search_path; documents/document_signatures/
  document_access_logs no schema (RECORD em vez de %ROWTYPE; RETURNS
  document_signatures->jsonb; document_access_logs sem tenant_id).
  document_share_links continua public.
- sign_document_by_signature_id (paciente LOGADO, nao e tenant_member):
  +p_tenant_id via _tenant_schema_unchecked + autorizacao por LINHA (signatario_id
  =uid OU email OU documento do paciente). RETURNS->jsonb.
- agendador_dias/slots_disponiveis (anon): resolvem tenant de agendador_
  configuracoes.tenant_id (public/F1b); agenda/recurrence no schema;
  agendador_configuracoes/solicitacoes ficam public.
- match_patient_by_phone (edge service): _tenant_schema_unchecked + REVOKE de
  anon/authenticated, GRANT service_role.
- list_my_signatures: CROSS-TENANT -> fan-out por schema (RETURN QUERY EXECUTE
  por tenant_schemas, tenant_id injetado; document_share_links global).
- RPCs public-only (create_patient_intake_request, get_patient_intake_invite_info,
  issue_patient_invite, rotate_*, agendador_gerar_slug): SEM mudanca (F1b public).

Frontend: signByPortal(tenantId, signatureId, hash) + composable resolve
tenant_id da linha (list_my_signatures retorna tenant_id). Build passa.

Gotcha: paciente assinante NAO e tenant_member -> auth por linha, nao membership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 15:51:22 -03:00
parent f079192698
commit 1243a12ced
3 changed files with 258 additions and 4 deletions
@@ -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_inicio<v_slot_fim AND b.hora_fim>v_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')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_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::time<v_slot_fim AND r.end_time::time>v_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)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_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;
@@ -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) {
+3 -1
View File
@@ -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
});