db: migration session_default_notes_field + schema regenerado

Migration 20260511000001 adiciona campo 'notes' (Observacao, textarea,
sort_order=30) como campo extra default no commitment determinado 'Sessao'.
Antes Sessao era a unica excecao entre os nativos — Leitura/Supervisao/
Aula/Analise ja tinham. Padroniza pra que a Observacao da sessao siga o
mesmo mecanismo de extra_fields dos outros, e o frontend remova a textarea
hardcoded do AgendaEventDialog (proximo commit).

Backfill: insere 'notes' em TODOS os commitments Sessao ja existentes
(idempotente). Forward-fix: substitui a funcao seed_determined_commitments
incluindo o bloco de Sessao + 'notes' pra novos tenants.

Schema regenerado via db.cjs schema-export pra refletir o estado pos-
migration. agenciapsi-db-dashboard.html regenerado pelo
generate-dashboard.cjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-11 16:56:59 -03:00
parent af8aee9188
commit dba595fd2d
33 changed files with 4037 additions and 474 deletions
+780 -19
View File
@@ -1,6 +1,6 @@
-- Functions: public
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z
-- Total: 153
-- Gerado automaticamente em 2026-05-11T16:53:50.918Z
-- Total: 172
CREATE FUNCTION public.__rls_ping() RETURNS text
LANGUAGE sql STABLE
@@ -8,6 +8,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
select 'ok'::text;
$$;
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH msgs AS (
SELECT
m.id,
m.tenant_id,
m.direction,
m.created_at,
m.patient_id,
m.from_number,
m.to_number,
-- mesma logica da view conversation_threads
COALESCE(
m.patient_id::text,
'anon:' || COALESCE(
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
'unknown'
)
) AS tk
FROM public.conversation_messages m
WHERE m.tenant_id = p_tenant_id
AND m.direction IN ('inbound', 'outbound')
AND m.created_at >= p_from
AND m.created_at <= p_to
),
with_prev AS (
SELECT *,
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
FROM msgs
),
run_starts AS (
-- Primeira mensagem de cada "run inbound"
SELECT tk, tenant_id, created_at AS inbound_started_at
FROM with_prev
WHERE direction = 'inbound'
AND (prev_direction IS NULL OR prev_direction = 'outbound')
)
SELECT
r.tk AS thread_key,
r.inbound_started_at,
o.created_at AS responded_at,
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
a.assigned_to AS responder_id
FROM run_starts r
LEFT JOIN LATERAL (
SELECT created_at
FROM public.conversation_messages m2
WHERE m2.tenant_id = r.tenant_id
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
AND m2.direction = 'outbound'
AND m2.created_at > r.inbound_started_at
ORDER BY m2.created_at
LIMIT 1
) o ON true
LEFT JOIN public.conversation_assignments a
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
$$;
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -172,6 +234,95 @@ BEGIN
END;
$$;
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_new_balance INT;
v_current_balance INT;
v_topup_net INT;
v_usage_total INT;
v_removable INT;
v_clean_note TEXT;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_required';
END IF;
IF p_amount IS NULL OR p_amount = 0 THEN
RAISE EXCEPTION 'amount_required';
END IF;
IF ABS(p_amount) > 1000 THEN
RAISE EXCEPTION 'amount_exceeds_limit_1000';
END IF;
IF p_admin_id IS NULL THEN
RAISE EXCEPTION 'admin_id_required';
END IF;
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
IF v_clean_note IS NOT NULL THEN
v_clean_note := LEFT(v_clean_note, 500);
END IF;
IF p_amount > 0 THEN
-- ADICIONAR
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
VALUES (p_tenant_id, p_amount)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
low_balance_alerted_at = NULL
RETURNING balance INTO v_new_balance;
ELSE
-- REMOVER (amount < 0)
SELECT balance INTO v_current_balance
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'tenant_has_no_balance';
END IF;
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_current_balance);
IF ABS(p_amount) > v_removable THEN
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance + p_amount -- p_amount ja e negativo
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
END IF;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, admin_id, note)
VALUES
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
RETURN v_new_balance;
END;
$$;
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -774,9 +925,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status IN ('cancelado', 'excluido')
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
@@ -1150,6 +1299,101 @@ BEGIN
END;
$$;
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_intake RECORD;
v_tenant_id UUID;
v_thread_key TEXT;
v_phone TEXT;
v_note_body TEXT;
v_admin_id UUID;
v_msg_id BIGINT;
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;
-- Tenant_id vem via owner_id (tenant_members)
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;
-- Normaliza telefone pra thread_key
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;
-- Nota com dados coletados
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')
);
-- Pega 1 admin do tenant pra preencher created_by da nota
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;
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
INSERT INTO public.conversation_messages
(tenant_id, channel, direction, from_number, to_number, body, provider,
provider_raw, kanban_status)
VALUES (
v_tenant_id, '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'
) RETURNING id INTO v_msg_id;
-- Cria nota interna
INSERT INTO public.conversation_notes
(tenant_id, thread_key, contact_number, body, created_by)
VALUES (
v_tenant_id, v_thread_key,
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
v_note_body, v_admin_id
);
-- Atualiza intake
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;
$$;
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -2753,6 +2997,84 @@ BEGIN
END;
$$;
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
r.responder_id AS therapist_id,
COUNT(*)::INT AS runs_count,
AVG(r.response_seconds)::INT AS avg_seconds,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE r.responder_id IS NOT NULL
GROUP BY r.responder_id
ORDER BY avg_seconds ASC;
$$;
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH runs AS (
SELECT r.inbound_started_at, r.response_seconds
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
),
bucketed AS (
SELECT
-- Janela alinhada a p_from: bucket_index * N dias + p_from
p_from + (
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
response_seconds
FROM runs
)
SELECT
bucket_start,
COUNT(*)::INT AS runs_count,
AVG(response_seconds)::INT AS avg_seconds
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
$$;
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) 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'
AS $$
DECLARE
v_threshold_seconds INT;
BEGIN
-- Pega threshold do SLA (se habilitado)
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
INTO v_threshold_seconds
FROM public.conversation_sla_rules
WHERE tenant_id = p_tenant_id;
RETURN QUERY
WITH runs AS (
SELECT r.response_seconds, r.responder_id
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
)
SELECT
COUNT(*)::INT AS runs_count,
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
COALESCE(MIN(response_seconds), 0) AS min_seconds,
COALESCE(MAX(response_seconds), 0) AS max_seconds,
v_threshold_seconds AS sla_threshold_seconds,
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
CASE
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
END AS sla_compliance_rate
FROM runs;
END;
$$;
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -2859,6 +3181,146 @@ BEGIN
END;
$$;
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_url TEXT;
v_key TEXT;
BEGIN
-- So dispara se status realmente mudou
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
RETURN NEW;
END IF;
-- Precisa de paciente vinculado (senao nao tem telefone)
IF NEW.patient_id IS NULL THEN
RETURN NEW;
END IF;
-- Busca settings
BEGIN
v_url := current_setting('app.settings.supabase_url', true);
v_key := current_setting('app.settings.service_role_key', true);
EXCEPTION WHEN OTHERS THEN
-- Settings nao configuradas — silencioso
RETURN NEW;
END;
IF v_url IS NULL OR v_key IS NULL THEN
RETURN NEW;
END IF;
-- Fire and forget (pg_net)
PERFORM net.http_post(
url := v_url || '/functions/v1/send-session-status-notification',
headers := jsonb_build_object(
'Authorization', 'Bearer ' || v_key,
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'event_id', NEW.id,
'old_status', OLD.status,
'new_status', NEW.status
)
);
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_thread_key TEXT;
BEGIN
-- So processa outbound
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
-- Calcula thread_key no mesmo padrao da view conversation_threads
v_thread_key := COALESCE(
NEW.patient_id::text,
'anon:' || COALESCE(NEW.to_number, 'unknown')
);
UPDATE public.conversation_sla_breaches
SET resolved_at = now(),
resolved_by_message_id = NEW.id
WHERE tenant_id = NEW.tenant_id
AND thread_key = v_thread_key
AND resolved_at IS NULL;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_detail TEXT;
BEGIN
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
IF NEW.balance < NEW.low_balance_threshold
AND NEW.low_balance_alerted_at IS NULL THEN
v_detail := format(
'Saldo atual: %s credito(s). Alerta configurado em %s. '
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
NEW.balance,
NEW.low_balance_threshold
);
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
INSERT INTO public.notifications
(owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT
u.user_id,
NEW.tenant_id,
'system_alert',
NEW.tenant_id,
'whatsapp_credits_balance',
jsonb_build_object(
'title', 'Saldo de WhatsApp baixo',
'detail', v_detail,
'severity', 'warn',
'deeplink', '/configuracoes/creditos-whatsapp'
)
FROM (
SELECT owner_id AS user_id
FROM public.notification_channels
WHERE tenant_id = NEW.tenant_id
AND channel = 'whatsapp'
AND is_active = true
AND deleted_at IS NULL
UNION
SELECT user_id
FROM public.tenant_members
WHERE tenant_id = NEW.tenant_id
AND role IN ('clinic_admin', 'tenant_admin')
AND status = 'active'
) u
WHERE u.user_id IS NOT NULL;
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
-- reseta alerted_at pra NULL (acontece em purchase/topup)
NEW.low_balance_alerted_at := now();
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -3177,6 +3639,48 @@ BEGIN
END;
$$;
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_balance INT := 0;
v_topup_net INT := 0;
v_usage_total INT := 0;
v_removable INT := 0;
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
SELECT COALESCE(b.balance, 0) INTO v_balance
FROM public.whatsapp_credits_balance b
WHERE b.tenant_id = p_tenant_id;
v_balance := COALESCE(v_balance, 0);
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind IN ('topup_manual', 'adjustment', 'refund');
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
FROM public.whatsapp_credits_transactions
WHERE tenant_id = p_tenant_id
AND kind = 'usage';
v_removable := GREATEST(0, v_topup_net - v_usage_total);
v_removable := LEAST(v_removable, v_balance);
RETURN QUERY SELECT
v_balance,
v_removable,
GREATEST(0, v_balance - v_removable),
v_topup_net,
v_usage_total;
END;
$$;
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -4410,6 +4914,120 @@ begin
end;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
WITH purchases AS (
SELECT p.paid_at, p.amount_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
),
bucketed AS (
SELECT
p_from + (
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
* p_bucket_days * interval '1 day'
) AS bucket_start,
amount_brl
FROM purchases
)
SELECT
bucket_start,
COUNT(*)::INT AS purchases_count,
SUM(amount_brl)::NUMERIC AS revenue_brl
FROM bucketed
GROUP BY bucket_start
ORDER BY bucket_start;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
COUNT(*)::INT AS purchases_count,
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
END AS avg_ticket_brl
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
p.package_id,
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
-- atual pra consolidar pacotes renomeados
COALESCE(
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
p.package_name
) AS package_name,
COUNT(*)::INT AS purchases_count,
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
SUM(p.credits)::INT AS credits_sold
FROM public.whatsapp_credit_purchases p
WHERE p.status = 'paid'
AND p.paid_at >= p_from
AND p.paid_at <= p_to
GROUP BY p.package_id, p.package_name
ORDER BY revenue_brl DESC
LIMIT 10;
END;
$$;
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'permission_denied';
END IF;
RETURN QUERY
SELECT
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
COALESCE(SUM(balance), 0)::INT AS current_balance,
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
END AS usage_rate,
COUNT(*)::INT AS tenants_with_balance
FROM public.whatsapp_credits_balance;
END;
$$;
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
@@ -4727,7 +5345,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
declare
v_id uuid;
begin
-- Sess??o (locked + sempre ativa)
-- Sessão (locked + sempre ativa)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
@@ -4735,7 +5353,7 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
end if;
-- Leitura
@@ -4749,7 +5367,7 @@ begin
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- Supervis??o
-- Supervisão
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -4757,10 +5375,10 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
end if;
-- Aula ??? (corrigido)
-- Aula
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
@@ -4771,7 +5389,7 @@ begin
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- An??lise pessoal
-- Análise pessoal
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -4779,13 +5397,26 @@ begin
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
end if;
-- -------------------------------------------------------
-- Campos padr??o (idempotentes por (commitment_id, key))
-- Campos padrão (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Leitura
select id into v_id
from public.determined_commitments
@@ -4805,11 +5436,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Supervis??o
-- Supervisão
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -4828,7 +5459,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
@@ -4851,11 +5482,11 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- An??lise
-- Análise
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -4874,7 +5505,7 @@ begin
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
end;
@@ -5056,6 +5687,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.conversation_sla_breaches
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_breach_id;
END;
$$;
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.conversation_sla_breaches
WHERE tenant_id = p_tenant_id
AND thread_key = p_thread_key
AND resolved_at IS NULL;
IF FOUND THEN
UPDATE public.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 public.conversation_sla_breaches
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
VALUES
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
@@ -6419,6 +7099,87 @@ BEGIN
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.whatsapp_connection_incidents
SET notified_at = now(),
notification_count = notification_count + 1
WHERE id = p_incident_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tenant_id UUID;
v_provider TEXT;
v_existing_id UUID;
v_new_id UUID;
BEGIN
-- Busca tenant/provider do channel
SELECT tenant_id, provider INTO v_tenant_id, v_provider
FROM public.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;
-- Ja tem aberto? Retorna o mesmo id (idempotente)
SELECT id INTO v_existing_id
FROM public.whatsapp_connection_incidents
WHERE channel_id = p_channel_id
AND resolved_at IS NULL;
IF FOUND THEN
-- Atualiza o incident existente com detalhes frescos
UPDATE public.whatsapp_connection_incidents
SET last_state = COALESCE(p_last_state, last_state),
details = COALESCE(p_details, details),
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
-- Abre novo
INSERT INTO public.whatsapp_connection_incidents
(tenant_id, channel_id, provider, kind, last_state, details)
VALUES
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_count INT := 0;
BEGIN
UPDATE public.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;
$$;
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE
AS $$