From 02acc88da5fb281ddab434916da3172cfcb04715 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sat, 13 Jun 2026 15:25:36 -0300 Subject: [PATCH] F6.2 Lote E: RPCs de cron/global roteadas/loopadas por tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB (supabase_admin, manual/f6_2e_cron_rpcs.supabase_admin.sql): - E2 (varrem TODOS os tenants via loop FROM tenant_schemas): cleanup/unstick_ notification_queue, sync_overdue_financial_records (EXECUTE format por schema), populate_notification_queue (set_config search_path por tenant; profiles global) - E1 (per-tenant via service_role): novo helper _tenant_schema_unchecked (sem is_tenant_member — service_role nao e membro; REVOKE de anon/authenticated). sla_open_breach, sla_mark_notified(+p_tenant_id), whatsapp_heartbeat_open_ incident/mark_notified/resolve(+p_tenant_id). convert_abandoned_intake_to_lead resolve tenant internamente (intake public/F1b -> writes no schema). first_response_stats/_runs: _tenant_route (user-facing, frontend ja passa p_tenant_id); _first_response_runs computa thread_key (coluna nao existe). - REVOKE das RPCs de servico de anon/authenticated; GRANT service_role Edge: whatsapp-heartbeat-check (tdb.rpc->admin.rpc + p_tenant_id nos heartbeat RPCs), conversation-sla-check (sla_mark_notified + p_tenant_id). Gotchas: (1) service_role nao e tenant_member -> helper unchecked + REVOKE; (2) conversation_messages nao tem coluna thread_key (computar); (3) DROP+CREATE de nova assinatura precisa dropar ambas p/ idempotencia. Smoke: E2 sync_overdue 13 across tenants; E1 sla_open_breach roteia; first_ response_stats user OK. Co-Authored-By: Claude Fable 5 --- .../manual/f6_2e_cron_rpcs.supabase_admin.sql | 308 ++++++++++++++++++ .../functions/conversation-sla-check/index.ts | 2 +- .../whatsapp-heartbeat-check/index.ts | 9 +- 3 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql diff --git a/database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql b/database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql new file mode 100644 index 0000000..8ab9f15 --- /dev/null +++ b/database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql @@ -0,0 +1,308 @@ +-- ============================================================================= +-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant +-- +-- ⚠️ APLICAR COMO supabase_admin. +-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \ +-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \ +-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql +-- +-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path +-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id). +-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas. +-- ============================================================================= + +BEGIN; + +-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por +-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de +-- anon/authenticated (só service_role/postgres chamam). +CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(p_tenant_id uuid) +RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_schema text; +BEGIN + IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF; + v_schema := public.tenant_schema_for(p_tenant_id); + IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF; + RETURN v_schema; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- E2 — crons globais: varrem todos os schemas +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.cleanup_notification_queue() +RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE t record; v_n int; v_total int := 0; +BEGIN + FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP + EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name); + GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n; + END LOOP; + RETURN v_total; +END $$; + +CREATE OR REPLACE FUNCTION public.unstick_notification_queue() +RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE t record; v_n int; v_total int := 0; +BEGIN + FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP + EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name); + GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n; + END LOOP; + RETURN v_total; +END $$; + +CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records() +RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE t record; v_n int; v_total int := 0; +BEGIN + FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP + EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name); + GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n; + END LOOP; + RETURN v_total; +END $$; + +-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles +-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT. +CREATE OR REPLACE FUNCTION public.populate_notification_queue() +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE t record; +BEGIN + FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP + PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true); + INSERT INTO notification_queue ( + owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key, + resolved_vars, recipient_address, scheduled_at, idempotency_key) + SELECT + ae.owner_id, ae.id, ae.patient_id, ch.channel, + 'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel, + ns.schedule_key, + jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'), + 'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'), + 'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'), + 'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'), + 'modalidade', COALESCE(ae.modalidade,'Presencial'), + 'titulo', COALESCE(ae.titulo,'Sessão')), + CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END, + CASE + WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start + THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start + WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end + THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start + ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END, + ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text + FROM agenda_eventos ae + JOIN patients p ON p.id = ae.patient_id + LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL + JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao' + JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL + CROSS JOIN LATERAL ( + SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp' + UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email' + UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch + LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL + WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now() + AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now() + ON CONFLICT (idempotency_key) DO NOTHING; + END LOOP; +END $$; + +-- ─────────────────────────────────────────────────────────────────────────── +-- E1 — chamadas per-tenant (p_tenant_id + route) +-- ─────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer) +RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_existing_id uuid; v_new_id uuid; +BEGIN + IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF; + PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); + SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL; + IF FOUND THEN + UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id; + RETURN v_existing_id; + END IF; + INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach) + VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id; + RETURN v_new_id; +END $$; + +DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid); +DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid); +CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); + UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id; +END $$; + +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb); +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb); +CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL) +RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_provider text; v_existing_id uuid; v_new_id uuid; +BEGIN + PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); + SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL; + IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF; + IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF; + SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL; + IF FOUND THEN + UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id; + RETURN v_existing_id; + END IF; + INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details) + VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id; + RETURN v_new_id; +END $$; + +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid); +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid); +CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); + UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id; +END $$; + +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid); +DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid); +CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid) +RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_count int := 0; +BEGIN + PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true); + UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int + WHERE channel_id = p_channel_id AND resolved_at IS NULL; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END $$; + +-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id +-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de +-- conversation_messages/notes vão pro schema do tenant resolvido. +CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) +RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE + v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text; + v_note_body text; v_admin_id uuid; +BEGIN + SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id; + IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF; + IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF; + SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id + ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1; + IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF; + v_schema := public.tenant_schema_for(v_tenant_id); + IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF; + + v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g'); + IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF; + IF v_phone = '' THEN v_phone := 'unknown'; END IF; + v_thread_key := 'anon:'||v_phone; + v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s', + E'\n',E'\n', COALESCE(v_intake.nome_completo,'—'), E'\n', COALESCE(v_intake.telefone,'—'), E'\n', + COALESCE(v_intake.email_principal,'—'), E'\n', COALESCE(v_intake.onde_nos_conheceu,'—'), E'\n',E'\n', + to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'), + to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI')); + + SELECT user_id INTO v_admin_id FROM public.tenant_members + WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1; + IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF; + + PERFORM set_config('search_path', v_schema || ',public,pg_temp', true); + INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status) + VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL, + format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')), + 'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us'); + INSERT INTO conversation_notes (thread_key, contact_number, body, created_by) + VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id); + + UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id; + RETURN p_intake_id; +END $$; + +-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio +-- search_path — _first_response_runs tem SET search_path próprio que resetaria). +CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz) +RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + RETURN QUERY + WITH base AS ( + SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key, + m.direction, m.created_at + FROM conversation_messages m + WHERE m.created_at >= p_from AND m.created_at < p_to + ), + inbound AS ( + SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at + FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key + ) + SELECT i.tk, i.inbound_started_at, + (SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at, + EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds, + a.assigned_to AS responder_id + FROM inbound i + LEFT JOIN conversation_assignments a ON a.thread_key = i.tk; +END $$; + +CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL) +RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_threshold_min integer; +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1; + v_threshold_min := COALESCE(v_threshold_min, 30); + RETURN QUERY + WITH runs AS ( + SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r + WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id) + ) + SELECT count(*)::int, + COALESCE(avg(response_seconds),0)::int, + COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int, + COALESCE(min(response_seconds),0)::int, + COALESCE(max(response_seconds),0)::int, + (v_threshold_min*60)::int, + count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int, + CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END + FROM runs; +END $$; + +-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership. +DO $g$ +DECLARE fn text; +BEGIN + FOREACH fn IN ARRAY ARRAY[ + 'sla_open_breach(uuid,text,uuid,timestamptz,integer)', + 'sla_mark_notified(uuid,uuid)', + 'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)', + 'whatsapp_heartbeat_mark_notified(uuid,uuid)', + 'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)', + 'convert_abandoned_intake_to_lead(uuid)', + 'cleanup_notification_queue()','unstick_notification_queue()', + 'sync_overdue_financial_records()','populate_notification_queue()' + ] LOOP + EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn); + EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn); + END LOOP; +END $g$; + +COMMIT; diff --git a/supabase/functions/conversation-sla-check/index.ts b/supabase/functions/conversation-sla-check/index.ts index 11ab5e4..8eb85ce 100644 --- a/supabase/functions/conversation-sla-check/index.ts +++ b/supabase/functions/conversation-sla-check/index.ts @@ -286,7 +286,7 @@ async function notifyBreach(tdb: SupabaseClient, admin: SupabaseClient, tenantId const { error: insertErr } = await tdb.from('notifications').insert(rows) if (insertErr) return false - await admin.rpc('sla_mark_notified', { p_breach_id: params.breach_id }) + await admin.rpc('sla_mark_notified', { p_tenant_id: tenantId, p_breach_id: params.breach_id }) return true } diff --git a/supabase/functions/whatsapp-heartbeat-check/index.ts b/supabase/functions/whatsapp-heartbeat-check/index.ts index 997a583..6bf2b3b 100644 --- a/supabase/functions/whatsapp-heartbeat-check/index.ts +++ b/supabase/functions/whatsapp-heartbeat-check/index.ts @@ -161,7 +161,7 @@ async function checkOneChannel(tdb: SupabaseClient, admin: SupabaseClient, tenan patch.metadata = newMeta await tdb.from('notification_channels').update(patch).eq('id', channel.id) - const { data: resolved } = await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id }) + const { data: resolved } = await admin.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_tenant_id: tenantId, p_channel_id: channel.id }) return { tenant_id: tenantId, channel_id: channel.id, @@ -238,7 +238,7 @@ async function checkOneChannel(tdb: SupabaseClient, admin: SupabaseClient, tenan }).eq('id', channel.id) // Resolve qualquer incident aberto desse channel (caso tenha sobrado de ciclo anterior) - await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id }) + await admin.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_tenant_id: tenantId, p_channel_id: channel.id }) return { tenant_id: tenantId, @@ -265,7 +265,8 @@ async function checkOneChannel(tdb: SupabaseClient, admin: SupabaseClient, tenan ...(fetchError ? { error: fetchError } : {}), reconnect_attempted: reconnectAttempted } - const { data: incidentId, error: incidentErr } = await tdb.rpc('whatsapp_heartbeat_open_incident', { + const { data: incidentId, error: incidentErr } = await admin.rpc('whatsapp_heartbeat_open_incident', { + p_tenant_id: tenantId, p_channel_id: channel.id, p_kind: kind, p_last_state: state || fetchError, @@ -365,7 +366,7 @@ async function notifyChannelStakeholders(tdb: SupabaseClient, admin: SupabaseCli // notifications é tenant → tdb (sem tenant_id no payload) await tdb.from('notifications').insert(rows) - await tdb.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id }) + await admin.rpc('whatsapp_heartbeat_mark_notified', { p_tenant_id: tenantId, p_incident_id: params.incident_id }) } Deno.serve(async (req) => {