F6.2 Lote E: RPCs de cron/global roteadas/loopadas por tenant

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 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 15:25:36 -03:00
parent d3620f99ea
commit 02acc88da5
3 changed files with 314 additions and 5 deletions
@@ -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;
@@ -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
}
@@ -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) => {