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:
@@ -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 $$
|
||||
|
||||
Reference in New Issue
Block a user