Files
agenciapsilmno/database-novo/schema/03_functions/public.sql
T
Leonardo 2644e60bb6 CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:05:24 -03:00

6427 lines
207 KiB
PL/PgSQL

-- Functions: public
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z
-- Total: 153
CREATE FUNCTION public.__rls_ping() RETURNS text
LANGUAGE sql STABLE
AS $$
select 'ok'::text;
$$;
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_intent record;
v_sub public.subscriptions;
v_days int;
v_user_id uuid;
v_plan_id uuid;
v_target text;
begin
-- l?? pela VIEW unificada
select * into v_intent
from public.subscription_intents
where id = p_intent_id;
if not found then
raise exception 'Intent n??o encontrado: %', p_intent_id;
end if;
if v_intent.status <> 'paid' then
raise exception 'Intent precisa estar paid para ativar assinatura';
end if;
-- resolve target e plan_id via plans.key
select p.id, p.target
into v_plan_id, v_target
from public.plans p
where p.key = v_intent.plan_key
limit 1;
if v_plan_id is null then
raise exception 'Plano n??o encontrado em plans.key = %', v_intent.plan_key;
end if;
v_target := lower(coalesce(v_target, ''));
-- ??? supervisor adicionado
if v_target not in ('clinic', 'therapist', 'supervisor') then
raise exception 'Target inv??lido em plans.target: %', v_target;
end if;
-- regra por target
if v_target = 'clinic' then
if v_intent.tenant_id is null then
raise exception 'Intent sem tenant_id';
end if;
else
-- therapist ou supervisor: vinculado ao user
v_user_id := v_intent.user_id;
if v_user_id is null then
v_user_id := v_intent.created_by_user_id;
end if;
end if;
if v_target in ('therapist', 'supervisor') and v_user_id is null then
raise exception 'N??o foi poss??vel determinar user_id para assinatura %.', v_target;
end if;
-- cancela assinatura ativa anterior
if v_target = 'clinic' then
update public.subscriptions
set status = 'cancelled',
cancelled_at = now()
where tenant_id = v_intent.tenant_id
and plan_id = v_plan_id
and status = 'active';
else
-- therapist ou supervisor
update public.subscriptions
set status = 'cancelled',
cancelled_at = now()
where user_id = v_user_id
and plan_id = v_plan_id
and status = 'active'
and tenant_id is null;
end if;
-- dura????o do plano (30 dias para mensal)
v_days := case
when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365
else 30
end;
-- cria nova assinatura
insert into public.subscriptions (
user_id,
plan_id,
status,
started_at,
expires_at,
cancelled_at,
activated_at,
tenant_id,
plan_key,
interval,
source,
created_at,
updated_at
)
values (
case when v_target = 'clinic' then null else v_user_id end,
v_plan_id,
'active',
now(),
now() + make_interval(days => v_days),
null,
now(),
case when v_target = 'clinic' then v_intent.tenant_id else null end,
v_intent.plan_key,
v_intent.interval,
'manual',
now(),
now()
)
returning * into v_sub;
-- grava v??nculo intent ??? subscription
if v_target = 'clinic' then
update public.subscription_intents_tenant
set subscription_id = v_sub.id
where id = p_intent_id;
else
update public.subscription_intents_personal
set subscription_id = v_sub.id
where id = p_intent_id;
end if;
return v_sub;
end;
$$;
CREATE FUNCTION public.add_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_kind text, p_purchase_id uuid DEFAULT NULL::uuid, p_admin_id uuid DEFAULT NULL::uuid, p_note text DEFAULT NULL::text) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_new_balance INT;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
END IF;
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
+ CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
low_balance_alerted_at = NULL -- reset alerta quando recebe creditos
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
VALUES
(p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_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'
AS $$
DECLARE
v_credit addon_credits%ROWTYPE;
v_balance_before INTEGER;
v_balance_after INTEGER;
v_tx_id UUID;
BEGIN
-- Upsert addon_credits
INSERT INTO addon_credits (tenant_id, addon_type, balance, total_purchased)
VALUES (p_tenant_id, p_addon_type, 0, 0)
ON CONFLICT (tenant_id, addon_type) DO NOTHING;
-- Lock e leitura
SELECT * INTO v_credit
FROM addon_credits
WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type
FOR UPDATE;
v_balance_before := v_credit.balance;
v_balance_after := v_credit.balance + p_amount;
-- Atualiza saldo
UPDATE addon_credits
SET balance = v_balance_after,
total_purchased = total_purchased + p_amount,
low_balance_notified = CASE WHEN v_balance_after > COALESCE(low_balance_threshold, 10) THEN false ELSE low_balance_notified END,
updated_at = now()
WHERE id = v_credit.id;
-- Registra transa????o
INSERT INTO addon_transactions (
tenant_id, addon_type, type, amount,
balance_before, balance_after,
product_id, description,
admin_user_id, payment_method, price_cents
) VALUES (
p_tenant_id, p_addon_type, 'purchase', p_amount,
v_balance_before, v_balance_after,
p_product_id, p_description,
auth.uid(), p_payment_method, p_price_cents
)
RETURNING id INTO v_tx_id;
RETURN jsonb_build_object(
'success', true,
'transaction_id', v_tx_id,
'balance_before', v_balance_before,
'balance_after', v_balance_after
);
END;
$$;
CREATE FUNCTION public.admin_delete_email_template_global(p_id uuid) RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
DELETE FROM public.email_templates_global WHERE id = p_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Template com id % n??o encontrado', p_id;
END IF;
RETURN true;
END;
$$;
CREATE FUNCTION public.admin_fix_plan_target(p_plan_key text, p_new_target text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_plan_id uuid;
begin
-- (opcional) restringe targets v??lidos
if p_new_target not in ('clinic','therapist') then
raise exception 'Target inv??lido: %', p_new_target using errcode='P0001';
end if;
-- trava o plano
select id into v_plan_id
from public.plans
where key = p_plan_key
for update;
if v_plan_id is null then
raise exception 'Plano n??o encontrado: %', p_plan_key using errcode='P0001';
end if;
-- seguran??a: n??o mexer se existe subscription
if exists (select 1 from public.subscriptions s where s.plan_id = v_plan_id) then
raise exception 'Plano % possui subscriptions. Migra????o bloqueada.', p_plan_key using errcode='P0001';
end if;
-- liga bypass SOMENTE nesta transa????o
perform set_config('app.plan_migration_bypass', '1', true);
update public.plans
set target = p_new_target
where id = v_plan_id;
end
$$;
CREATE FUNCTION public.admin_upsert_email_template_global(p_id uuid DEFAULT NULL::uuid, p_key text DEFAULT NULL::text, p_domain text DEFAULT NULL::text, p_channel text DEFAULT 'email'::text, p_subject text DEFAULT NULL::text, p_body_html text DEFAULT NULL::text, p_body_text text DEFAULT NULL::text, p_is_active boolean DEFAULT true, p_variables jsonb DEFAULT '{}'::jsonb) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_result jsonb;
v_id uuid;
BEGIN
-- UPDATE existente
IF p_id IS NOT NULL THEN
UPDATE public.email_templates_global
SET
subject = COALESCE(p_subject, subject),
body_html = COALESCE(p_body_html, body_html),
body_text = p_body_text,
is_active = p_is_active,
variables = COALESCE(p_variables, variables),
version = version + 1
WHERE id = p_id
RETURNING to_jsonb(email_templates_global.*) INTO v_result;
IF v_result IS NULL THEN
RAISE EXCEPTION 'Template com id % n??o encontrado', p_id;
END IF;
RETURN v_result;
END IF;
-- INSERT novo
IF p_key IS NULL OR p_domain IS NULL OR p_subject IS NULL OR p_body_html IS NULL THEN
RAISE EXCEPTION 'key, domain, subject e body_html s??o obrigat??rios para novo template';
END IF;
INSERT INTO public.email_templates_global (key, domain, channel, subject, body_html, body_text, is_active, variables)
VALUES (p_key, p_domain, p_channel, p_subject, p_body_html, p_body_text, p_is_active, p_variables)
RETURNING to_jsonb(email_templates_global.*) INTO v_result;
RETURN v_result;
END;
$$;
CREATE FUNCTION public.agenda_cfg_sync() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if new.agenda_view_mode = 'custom' then
new.usar_horario_admin_custom := true;
new.admin_inicio_visualizacao := new.agenda_custom_start;
new.admin_fim_visualizacao := new.agenda_custom_end;
else
new.usar_horario_admin_custom := false;
end if;
return new;
end;
$$;
CREATE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) RETURNS TABLE(data date, tem_slots boolean)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_agora timestamptz;
v_data date;
v_data_inicio date;
v_data_fim date;
v_db_dow int;
v_tem_slot boolean;
v_bloqueado boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
-- ?????? Dia inteiro bloqueado? (agenda_bloqueios) ???????????????????????????????????????????????????????????????????????????
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= v_data
AND COALESCE(b.data_fim, v_data) >= v_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_bloqueado;
IF v_bloqueado THEN
v_data := v_data + 1;
CONTINUE;
END IF;
-- ?????? Tem slots dispon??veis no dia? ???????????????????????????????????????????????????????????????????????????????????????????????????????????????
SELECT EXISTS (
SELECT 1 FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
AND (v_data::text || ' ' || s.time::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo'
>= v_agora + (v_antecedencia || ' hours')::interval
) INTO v_tem_slot;
IF v_tem_slot THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_slug text;
v_exists boolean;
BEGIN
-- s?? gera se ativou e n??o tem slug ainda
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
LOOP
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
SELECT EXISTS (
SELECT 1 FROM public.agendador_configuracoes
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
NEW.link_slug := v_slug;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_antecedencia int;
v_agora timestamptz;
v_db_dow int;
v_slot time;
v_slot_fim time;
v_slot_ts timestamptz;
v_ocupado boolean;
-- loop de recorr??ncias
v_rule RECORD;
v_rule_start_dow int;
v_first_occ date;
v_day_diff int;
v_ex_type text;
BEGIN
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int;
-- ?????? Dia inteiro bloqueado? (agenda_bloqueios sem hora) ?????????????????????????????????????????????????????????
-- Se sim, n??o h?? nenhum slot dispon??vel ??? retorna vazio.
IF EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) THEN
RETURN;
END IF;
FOR v_slot IN
SELECT s.time
FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
ORDER BY s.time
LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
v_ocupado := false;
-- ?????? Anteced??ncia m??nima ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- ?????? Bloqueio de hor??rio espec??fico (agenda_bloqueios com hora) ?????????????????????????????????
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NOT NULL
AND b.hora_inicio < v_slot_fim
AND b.hora_fim > v_slot
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_ocupado;
END IF;
-- ?????? Eventos avulsos internos (agenda_eventos) ????????????????????????????????????????????????????????????????????????????????????
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos e
WHERE e.owner_id = v_owner_id
AND e.status::text NOT IN ('cancelado', 'faltou')
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- ?????? Recorr??ncias ativas (recurrence_rules) ?????????????????????????????????????????????????????????????????????????????????????????????
IF NOT v_ocupado THEN
FOR v_rule IN
SELECT
r.id,
r.start_date::date AS start_date,
r.end_date::date AS end_date,
r.start_time::time AS start_time,
r.end_time::time AS end_time,
COALESCE(r.interval, 1)::int AS interval
FROM public.recurrence_rules r
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND p_data >= r.start_date::date
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
AND v_db_dow = ANY(r.weekdays)
AND r.start_time::time < v_slot_fim
AND r.end_time::time > v_slot
LOOP
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type
FROM public.recurrence_exceptions ex
WHERE ex.recurrence_id = v_rule.id
AND ex.original_date = p_data
LIMIT 1;
IF v_ex_type IS NULL OR v_ex_type NOT IN (
'cancel_session', 'patient_missed',
'therapist_canceled', 'holiday_block',
'reschedule_session'
) THEN
v_ocupado := true;
EXIT;
END IF;
END IF;
END LOOP;
END IF;
-- ?????? Recorr??ncias remarcadas para este dia ????????????????????????????????????????????????????????????????????????????????????????????????
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1
FROM public.recurrence_exceptions ex
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND ex.type = 'reschedule_session'
AND ex.new_date = p_data
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
) INTO v_ocupado;
END IF;
-- ?????? Solicita????es p??blicas pendentes ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes sol
WHERE sol.owner_id = v_owner_id
AND sol.status = 'pendente'
AND sol.data_solicitada = p_data
AND sol.hora_solicitada = v_slot
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END LOOP;
END;
$$;
CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_price NUMERIC(10,2);
v_services_total NUMERIC(10,2);
v_already_billed BOOLEAN;
BEGIN
-- ?????? Guards de sa??da r??pida ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
-- S?? processa quando o status muda PARA 'realizado'
IF NEW.status::TEXT <> 'realizado' THEN
RETURN NEW;
END IF;
-- S?? processa quando houve mudan??a real de status
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
RETURN NEW;
END IF;
-- S?? sess??es (n??o bloqueios, feriados, etc.)
IF NEW.tipo::TEXT <> 'sessao' THEN
RETURN NEW;
END IF;
-- Paciente obrigat??rio para vincular a cobran??a
IF NEW.patient_id IS NULL THEN
RETURN NEW;
END IF;
-- Sess??es de pacote t??m cobran??a gerenciada por billing_contract
IF NEW.billing_contract_id IS NOT NULL THEN
RETURN NEW;
END IF;
-- Idempot??ncia: j?? existe financial_record para este evento?
SELECT billed INTO v_already_billed
FROM public.agenda_eventos
WHERE id = NEW.id;
IF v_already_billed = TRUE THEN
-- Confirma no financial_records tamb??m (dupla verifica????o)
IF EXISTS (
SELECT 1 FROM public.financial_records
WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL
) THEN
RETURN NEW;
END IF;
END IF;
-- ?????? Busca do pre??o ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
v_price := NULL;
-- Prioridade 1: soma dos servi??os da regra de recorr??ncia
IF NEW.recurrence_id IS NOT NULL THEN
SELECT COALESCE(SUM(rrs.final_price), 0)
INTO v_services_total
FROM public.recurrence_rule_services rrs
WHERE rrs.rule_id = NEW.recurrence_id;
IF v_services_total > 0 THEN
v_price := v_services_total;
END IF;
-- Prioridade 2: price direto da regra (fallback se sem servi??os)
IF v_price IS NULL OR v_price = 0 THEN
SELECT price INTO v_price
FROM public.recurrence_rules
WHERE id = NEW.recurrence_id;
END IF;
END IF;
-- Prioridade 3: price do pr??prio evento de agenda
IF v_price IS NULL OR v_price = 0 THEN
v_price := NEW.price;
END IF;
-- Sem pre??o ??? n??o criar registro (n??o ?? erro, apenas skip silencioso)
IF v_price IS NULL OR v_price <= 0 THEN
RETURN NEW;
END IF;
-- ?????? Cria????o do financial_record ???????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
INSERT INTO public.financial_records (
owner_id,
tenant_id,
patient_id,
agenda_evento_id,
type,
amount,
discount_amount,
final_amount,
clinic_fee_pct,
clinic_fee_amount,
status,
due_date
-- payment_method: NULL at?? o momento do pagamento (mark_as_paid preenche)
) VALUES (
NEW.owner_id,
NEW.tenant_id,
NEW.patient_id,
NEW.id,
'receita',
v_price,
0,
v_price,
0, -- clinic_fee_pct: sem campo de configura????o global no schema atual.
0, -- clinic_fee_amount: calculado manualmente ou via update posterior.
'pending',
(NEW.inicio_em::DATE + 7) -- vencimento padr??o: 7 dias ap??s a sess??o
);
-- ?????? Marca sess??o como billed ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
-- UPDATE em billed (n??o em status) ??? n??o re-dispara este trigger
UPDATE public.agenda_eventos
SET billed = TRUE
WHERE id = NEW.id;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Log silencioso: nunca bloquear a agenda por falha financeira
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%',
NEW.id, SQLERRM;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT NOT EXISTS (
SELECT 1 FROM public.agenda_eventos WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.recurrence_rules WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.billing_contracts WHERE patient_id = p_patient_id
);
$$;
CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- WhatsApp opt-out
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'whatsapp'
);
END IF;
-- Email opt-out
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'email'
);
END IF;
-- SMS opt-out
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'sms'
);
END IF;
RETURN NEW;
END;
$$;
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
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_canceled integer;
BEGIN
UPDATE public.notification_queue
SET status = 'cancelado',
updated_at = now()
WHERE patient_id = p_patient_id
AND status IN ('pendente', 'processando')
AND (p_channel IS NULL OR channel = p_channel)
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
GET DIAGNOSTICS v_canceled = ROW_COUNT;
RETURN v_canceled;
END;
$$;
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.recurrence_rules
SET
end_date = p_from_date - INTERVAL '1 day',
open_ended = false,
status = CASE
WHEN p_from_date <= start_date THEN 'cancelado'
ELSE status
END,
updated_at = now()
WHERE id = p_recurrence_id;
END;
$$;
CREATE FUNCTION public.cancel_subscription(p_subscription_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_sub public.subscriptions;
v_owner_type text;
v_owner_ref uuid;
begin
select *
into v_sub
from public.subscriptions
where id = p_subscription_id
for update;
if not found then
raise exception 'Subscription n??o encontrada';
end if;
if v_sub.status = 'canceled' then
return v_sub;
end if;
if v_sub.tenant_id is not null then
v_owner_type := 'clinic';
v_owner_ref := v_sub.tenant_id;
elsif v_sub.user_id is not null then
v_owner_type := 'therapist';
v_owner_ref := v_sub.user_id;
else
v_owner_type := null;
v_owner_ref := null;
end if;
update public.subscriptions
set status = 'canceled',
cancel_at_period_end = false,
updated_at = now()
where id = p_subscription_id
returning * into v_sub;
insert into public.subscription_events(
subscription_id,
owner_id,
owner_type,
owner_ref,
event_type,
old_plan_id,
new_plan_id,
created_by,
reason,
source,
metadata
)
values (
v_sub.id,
v_owner_ref,
v_owner_type,
v_owner_ref,
'canceled',
v_sub.plan_id,
v_sub.plan_id,
auth.uid(),
'Cancelamento manual via admin',
'admin_panel',
jsonb_build_object('previous_status', 'active')
);
if v_owner_ref is not null then
insert into public.entitlements_invalidation(owner_id, changed_at)
values (v_owner_ref, now())
on conflict (owner_id)
do update set changed_at = excluded.changed_at;
end if;
return v_sub;
end;
$$;
CREATE FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone DEFAULT now()) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.agenda_eventos
SET status = 'cancelado',
updated_at = now()
WHERE serie_id = p_serie_id
AND inicio_em >= p_a_partir_de
AND status NOT IN ('realizado', 'cancelado');
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
CREATE FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_sub public.subscriptions;
v_old_plan uuid;
v_new_key text;
v_owner_type text;
v_owner_ref uuid;
v_new_target text;
v_sub_target text;
begin
select *
into v_sub
from public.subscriptions
where id = p_subscription_id
for update;
if not found then
raise exception 'Subscription n??o encontrada';
end if;
v_old_plan := v_sub.plan_id;
if v_old_plan = p_new_plan_id then
return v_sub;
end if;
select key, target
into v_new_key, v_new_target
from public.plans
where id = p_new_plan_id;
if v_new_key is null then
raise exception 'Plano n??o encontrado';
end if;
v_new_target := lower(coalesce(v_new_target, ''));
v_sub_target := case
when v_sub.tenant_id is not null then 'clinic'
else 'therapist'
end;
if v_new_target <> v_sub_target then
raise exception 'Plano inv??lido para este tipo de assinatura. Assinatura ?? % e o plano ?? %.',
v_sub_target, v_new_target
using errcode = 'P0001';
end if;
if v_sub.tenant_id is not null then
v_owner_type := 'clinic';
v_owner_ref := v_sub.tenant_id;
elsif v_sub.user_id is not null then
v_owner_type := 'therapist';
v_owner_ref := v_sub.user_id;
else
v_owner_type := null;
v_owner_ref := null;
end if;
update public.subscriptions
set plan_id = p_new_plan_id,
plan_key = v_new_key,
updated_at = now()
where id = p_subscription_id
returning * into v_sub;
insert into public.subscription_events(
subscription_id,
owner_id,
owner_type,
owner_ref,
event_type,
old_plan_id,
new_plan_id,
created_by,
reason,
source,
metadata
)
values (
v_sub.id,
v_owner_ref,
v_owner_type,
v_owner_ref,
'plan_changed',
v_old_plan,
p_new_plan_id,
auth.uid(),
'Plan change via DEV menu',
'dev_menu',
jsonb_build_object(
'previous_plan', v_old_plan,
'new_plan', p_new_plan_id,
'new_plan_key', v_new_key,
'new_plan_target', v_new_target
)
);
if v_owner_ref is not null then
insert into public.entitlements_invalidation (owner_id, changed_at)
values (v_owner_ref, now())
on conflict (owner_id)
do update set changed_at = excluded.changed_at;
end if;
return v_sub;
end;
$$;
CREATE FUNCTION public.check_rate_limit(p_ip_hash text, p_endpoint text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$$;
CREATE FUNCTION public.cleanup_expired_math_challenges() RETURNS integer
LANGUAGE sql SECURITY DEFINER
SET search_path TO 'public'
AS $$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$$;
CREATE FUNCTION public.cleanup_notification_queue() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_deleted integer;
BEGIN
DELETE FROM public.notification_queue
WHERE status IN ('enviado', 'cancelado', 'ignorado')
AND created_at < now() - interval '90 days';
GET DIAGNOSTICS v_deleted = ROW_COUNT;
RETURN v_deleted;
END;
$$;
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid;
v_tenant uuid;
v_name text;
begin
v_uid := auth.uid();
if v_uid is null then
raise exception 'Not authenticated';
end if;
v_name := nullif(trim(coalesce(p_name, '')), '');
if v_name is null then
v_name := 'Cl??nica';
end if;
insert into public.tenants (name, kind, created_at)
values (v_name, 'clinic', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
return v_tenant;
end;
$$;
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_existing public.financial_records%ROWTYPE;
v_new public.financial_records%ROWTYPE;
BEGIN
-- Idempot??ncia: retorna o registro existente se j?? foi criado
SELECT * INTO v_existing
FROM public.financial_records
WHERE agenda_evento_id = p_agenda_evento_id
AND deleted_at IS NULL
LIMIT 1;
IF FOUND THEN
RETURN NEXT v_existing;
RETURN;
END IF;
-- Cria o novo registro
INSERT INTO public.financial_records (
tenant_id,
owner_id,
patient_id,
agenda_evento_id,
amount,
discount_amount,
final_amount,
status,
due_date
) VALUES (
p_tenant_id,
p_owner_id,
p_patient_id,
p_agenda_evento_id,
p_amount,
0,
p_amount,
'pending',
p_due_date
)
RETURNING * INTO v_new;
-- Marca o evento da agenda como billed = true
UPDATE public.agenda_eventos
SET billed = TRUE
WHERE id = p_agenda_evento_id;
RETURN NEXT v_new;
END;
$$;
CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_owner uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_id uuid;
begin
select owner_id, active, expires_at, max_uses, uses
into v_owner, v_active, v_expires, v_max_uses, v_uses
from public.patient_invites
where token = p_token
limit 1;
if v_owner is null then
raise exception 'Token inv??lido';
end if;
if v_active is not true then
raise exception 'Link desativado';
end if;
if v_expires is not null and now() > v_expires then
raise exception 'Link expirado';
end if;
if v_max_uses is not null and v_uses >= v_max_uses then
raise exception 'Limite de uso atingido';
end if;
if p_name is null or length(trim(p_name)) = 0 then
raise exception 'Nome ?? obrigat??rio';
end if;
insert into public.patient_intake_requests
(owner_id, token, name, email, phone, notes, consent, status)
values
(v_owner, p_token, trim(p_name),
nullif(lower(trim(p_email)), ''),
nullif(trim(p_phone), ''),
nullif(trim(p_notes), ''),
coalesce(p_consent, false),
'new')
returning id into v_id;
update public.patient_invites
set uses = uses + 1
where token = p_token;
return v_id;
end;
$$;
CREATE FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
-- Whitelists para campos tipados
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Carrega invite e valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Resolver tenant_id (A#19)
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
-- ───────────────────────────────────────────────────────────────────────
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
-- Nome obrigatório (max 200)
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
RAISE EXCEPTION 'Nome é obrigatório';
END IF;
IF length(v_nome) > 200 THEN
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
END IF;
-- Email principal obrigatório + lower + max 120
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
RAISE EXCEPTION 'E-mail é obrigatório';
END IF;
IF length(v_email) > 120 THEN
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail inválido';
END IF;
-- Email alternativo opcional mas validado se presente
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
RAISE EXCEPTION 'E-mail alternativo muito longo';
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail alternativo inválido';
END IF;
END IF;
-- Consent obrigatório
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
RAISE EXCEPTION 'Consentimento é obrigatório';
END IF;
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT com sanitização inline
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
-- do terapeuta, não deve vir do paciente.
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id,
tenant_id,
token,
status,
consent,
nome_completo,
email_principal,
email_alternativo,
telefone,
telefone_alternativo,
avatar_url,
data_nascimento,
cpf,
rg,
genero,
estado_civil,
profissao,
escolaridade,
nacionalidade,
naturalidade,
cep,
pais,
cidade,
estado,
endereco,
numero,
complemento,
bairro,
observacoes,
encaminhado_por,
onde_nos_conheceu
)
VALUES (
v_owner_id,
v_tenant_id,
p_token,
'new',
v_consent,
v_nome,
v_email,
v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero,
v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
-- Incrementa contador de uso (A#16)
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
RETURN v_intake_id;
END;
$_$;
CREATE FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb, p_client_info text DEFAULT NULL::text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
v_err_msg text;
v_err_code text;
v_clean_info text;
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
BEGIN
-- Sanitiza client_info recebido (cap + trim)
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
-- ───────────────────────────────────────────────────────────────────────
-- Resolve invite + valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
v_err_code := 'TOKEN_INVALID';
v_err_msg := 'Token inválido';
-- Log + raise (owner_id NULL porque token não bateu)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
v_err_code := 'TOKEN_DISABLED';
v_err_msg := 'Link desativado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
v_err_code := 'TOKEN_EXPIRED';
v_err_msg := 'Link expirado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
v_err_code := 'TOKEN_MAX_USES';
v_err_msg := 'Limite de uso atingido';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
-- Resolve tenant_id se invite não tiver (A#19)
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_nome) > 200 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_email) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
END IF;
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id, tenant_id, token, status, consent,
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
avatar_url,
data_nascimento, cpf, rg, genero, estado_civil,
profissao, escolaridade, nacionalidade, naturalidade,
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
observacoes, encaminhado_por, onde_nos_conheceu
)
VALUES (
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
v_nome, v_email, v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero, v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
-- Log de sucesso (best-effort, não propaga erro)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RETURN v_intake_id;
END;
$_$;
CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_admin_id uuid;
v_role text;
v_token text;
v_expires timestamp with time zone;
v_session support_sessions;
BEGIN
-- Verifica autentica????o
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'N??o autenticado.' USING ERRCODE = 'P0001';
END IF;
-- Verifica role saas_admin
SELECT role INTO v_role
FROM public.profiles
WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sess??es de suporte.'
USING ERRCODE = 'P0002';
END IF;
-- Valida TTL (1 a 120 minutos)
IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
RAISE EXCEPTION 'TTL inv??lido. Use entre 1 e 120 minutos.'
USING ERRCODE = 'P0003';
END IF;
-- Valida tenant
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'Tenant n??o encontrado.'
USING ERRCODE = 'P0004';
END IF;
-- Gera token ??nico (64 chars hex, sem pgcrypto)
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
v_expires := now() + (p_ttl_minutes || ' minutes')::interval;
-- Insere sess??o
INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
RETURNING * INTO v_session;
RETURN json_build_object(
'token', v_session.token,
'expires_at', v_session.expires_at,
'session_id', v_session.id
);
END;
$$;
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_payout public.therapist_payouts%ROWTYPE;
v_total_sessions INTEGER;
v_gross NUMERIC(10,2);
v_clinic_fee NUMERIC(10,2);
v_net NUMERIC(10,2);
BEGIN
-- ?????? Verifica????o de permiss??o ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
-- Apenas o pr??prio terapeuta ou o tenant_admin pode criar o repasse
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
RAISE EXCEPTION 'Sem permiss??o para criar repasse para este terapeuta.';
END IF;
-- ?????? Verifica se j?? existe repasse para o mesmo per??odo ???????????????????????????????????????????????????
IF EXISTS (
SELECT 1 FROM public.therapist_payouts
WHERE owner_id = p_therapist_id
AND tenant_id = p_tenant_id
AND period_start = p_period_start
AND period_end = p_period_end
AND status <> 'cancelled'
) THEN
RAISE EXCEPTION
'J?? existe um repasse ativo para o per??odo % a % deste terapeuta.',
p_period_start, p_period_end;
END IF;
-- ?????? Agrega os financial_records eleg??veis ??????????????????????????????????????????????????????????????????????????????????????????
-- Eleg??veis: paid, receita, owner=terapeuta, tenant correto, paid_at no per??odo,
-- n??o soft-deleted, ainda n??o vinculados a nenhum payout.
SELECT
COUNT(*) AS total_sessions,
COALESCE(SUM(amount), 0) AS gross_amount,
COALESCE(SUM(clinic_fee_amount), 0) AS clinic_fee_total,
COALESCE(SUM(net_amount), 0) AS net_amount
INTO
v_total_sessions, v_gross, v_clinic_fee, v_net
FROM public.financial_records fr
WHERE fr.owner_id = p_therapist_id
AND fr.tenant_id = p_tenant_id
AND fr.type = 'receita'
AND fr.status = 'paid'
AND fr.deleted_at IS NULL
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (
SELECT 1 FROM public.therapist_payout_records tpr
WHERE tpr.financial_record_id = fr.id
);
-- Sem registros eleg??veis ??? n??o criar payout vazio
IF v_total_sessions = 0 THEN
RAISE EXCEPTION
'Nenhum registro financeiro eleg??vel encontrado para o per??odo % a %.',
p_period_start, p_period_end;
END IF;
-- ?????? Cria o repasse ???????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
INSERT INTO public.therapist_payouts (
owner_id,
tenant_id,
period_start,
period_end,
total_sessions,
gross_amount,
clinic_fee_total,
net_amount,
status
) VALUES (
p_therapist_id,
p_tenant_id,
p_period_start,
p_period_end,
v_total_sessions,
v_gross,
v_clinic_fee,
v_net,
'pending'
)
RETURNING * INTO v_payout;
-- ?????? Vincula os financial_records ao repasse ????????????????????????????????????????????????????????????????????????????????????
INSERT INTO public.therapist_payout_records (payout_id, financial_record_id)
SELECT v_payout.id, fr.id
FROM public.financial_records fr
WHERE fr.owner_id = p_therapist_id
AND fr.tenant_id = p_tenant_id
AND fr.type = 'receita'
AND fr.status = 'paid'
AND fr.deleted_at IS NULL
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (
SELECT 1 FROM public.therapist_payout_records tpr
WHERE tpr.financial_record_id = fr.id
);
RETURN v_payout;
END;
$$;
CREATE FUNCTION public.current_member_id(p_tenant_id uuid) RETURNS uuid
LANGUAGE sql STABLE
AS $$
select tm.id
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
limit 1
$$;
CREATE FUNCTION public.current_member_role(p_tenant_id uuid) RETURNS text
LANGUAGE sql STABLE
AS $$
select tm.role
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
limit 1
$$;
CREATE FUNCTION public.debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Consumo'::text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_credit addon_credits%ROWTYPE;
v_balance_before INTEGER;
v_balance_after INTEGER;
BEGIN
-- Lock e leitura
SELECT * INTO v_credit
FROM addon_credits
WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type AND is_active = true
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'reason', 'no_credits', 'balance', 0);
END IF;
-- Verifica saldo
IF v_credit.balance <= 0 THEN
RETURN jsonb_build_object('success', false, 'reason', 'insufficient_balance', 'balance', 0);
END IF;
-- Verifica rate limit di??rio
IF v_credit.daily_limit IS NOT NULL THEN
-- Reset se passou do dia
IF v_credit.daily_reset_at IS NULL OR v_credit.daily_reset_at < date_trunc('day', now()) THEN
UPDATE addon_credits SET daily_used = 0, daily_reset_at = date_trunc('day', now()) + interval '1 day' WHERE id = v_credit.id;
v_credit.daily_used := 0;
END IF;
IF v_credit.daily_used >= v_credit.daily_limit THEN
RETURN jsonb_build_object('success', false, 'reason', 'daily_limit_reached', 'balance', v_credit.balance);
END IF;
END IF;
-- Verifica rate limit hor??rio
IF v_credit.hourly_limit IS NOT NULL THEN
IF v_credit.hourly_reset_at IS NULL OR v_credit.hourly_reset_at < date_trunc('hour', now()) THEN
UPDATE addon_credits SET hourly_used = 0, hourly_reset_at = date_trunc('hour', now()) + interval '1 hour' WHERE id = v_credit.id;
v_credit.hourly_used := 0;
END IF;
IF v_credit.hourly_used >= v_credit.hourly_limit THEN
RETURN jsonb_build_object('success', false, 'reason', 'hourly_limit_reached', 'balance', v_credit.balance);
END IF;
END IF;
-- Verifica expira????o
IF v_credit.expires_at IS NOT NULL AND v_credit.expires_at < now() THEN
RETURN jsonb_build_object('success', false, 'reason', 'credits_expired', 'balance', v_credit.balance);
END IF;
v_balance_before := v_credit.balance;
v_balance_after := v_credit.balance - 1;
-- Debita
UPDATE addon_credits
SET balance = v_balance_after,
total_consumed = total_consumed + 1,
daily_used = COALESCE(daily_used, 0) + 1,
hourly_used = COALESCE(hourly_used, 0) + 1,
updated_at = now()
WHERE id = v_credit.id;
-- Registra transa????o
INSERT INTO addon_transactions (
tenant_id, addon_type, type, amount,
balance_before, balance_after,
queue_id, description
) VALUES (
p_tenant_id, p_addon_type, 'consume', -1,
v_balance_before, v_balance_after,
p_queue_id, p_description
);
RETURN jsonb_build_object(
'success', true,
'balance_before', v_balance_before,
'balance_after', v_balance_after
);
END;
$$;
CREATE FUNCTION public.deduct_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_conversation_message_id bigint DEFAULT NULL::bigint, p_note text DEFAULT NULL::text) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_new_balance INT;
v_row RECORD;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
-- Lock a linha e valida saldo
SELECT balance, low_balance_threshold
INTO v_row
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
IF v_row.balance < p_amount THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance - p_amount,
lifetime_used = lifetime_used + p_amount
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, conversation_message_id, note)
VALUES
(p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);
RETURN v_new_balance;
END;
$$;
CREATE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_is_native boolean;
v_fields int := 0;
v_logs int := 0;
v_parent int := 0;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.status = 'active'
) then
raise exception 'Not allowed';
end if;
select dc.is_native
into v_is_native
from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
if v_is_native is null then
raise exception 'Commitment not found';
end if;
if v_is_native = true then
raise exception 'Cannot delete native commitment';
end if;
delete from public.determined_commitment_fields
where tenant_id = p_tenant_id
and commitment_id = p_commitment_id;
get diagnostics v_fields = row_count;
delete from public.commitment_time_logs
where tenant_id = p_tenant_id
and commitment_id = p_commitment_id;
get diagnostics v_logs = row_count;
delete from public.determined_commitments
where tenant_id = p_tenant_id
and id = p_commitment_id;
get diagnostics v_parent = row_count;
if v_parent <> 1 then
raise exception 'Parent not deleted (RLS/owner issue).';
end if;
return jsonb_build_object(
'ok', true,
'deleted', jsonb_build_object(
'fields', v_fields,
'logs', v_logs,
'commitment', v_parent
)
);
end;
$$;
CREATE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_is_native boolean;
v_fields_deleted int := 0;
v_logs_deleted int := 0;
v_commitment_deleted int := 0;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.status = 'active'
) then
raise exception 'Not allowed';
end if;
select dc.is_native
into v_is_native
from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
if v_is_native is null then
raise exception 'Commitment not found for tenant';
end if;
if v_is_native = true then
raise exception 'Cannot delete native commitment';
end if;
delete from public.determined_commitment_fields f
where f.tenant_id = p_tenant_id
and f.commitment_id = p_commitment_id;
get diagnostics v_fields_deleted = row_count;
delete from public.commitment_time_logs l
where l.tenant_id = p_tenant_id
and l.commitment_id = p_commitment_id;
get diagnostics v_logs_deleted = row_count;
delete from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
get diagnostics v_commitment_deleted = row_count;
if v_commitment_deleted <> 1 then
raise exception 'Delete did not remove the commitment (tenant mismatch?)';
end if;
return jsonb_build_object(
'ok', true,
'tenant_id', p_tenant_id,
'commitment_id', p_commitment_id,
'deleted', jsonb_build_object(
'fields', v_fields_deleted,
'logs', v_logs_deleted,
'commitment', v_commitment_deleted
)
);
end;
$$;
CREATE FUNCTION public.delete_plan_safe(p_plan_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_active_count int;
v_plan_key text;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
END IF;
IF p_plan_id IS NULL THEN
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
END IF;
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
IF v_plan_key IS NULL THEN
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
END IF;
SELECT COUNT(*) INTO v_active_count
FROM public.subscriptions
WHERE plan_id = p_plan_id
AND status = 'active';
IF v_active_count > 0 THEN
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
v_plan_key, v_active_count
USING ERRCODE = 'P0001';
END IF;
-- desativa preços ativos antes de deletar
UPDATE public.plan_prices
SET is_active = false,
active_to = now()
WHERE plan_id = p_plan_id
AND is_active = true;
DELETE FROM public.plans WHERE id = p_plan_id;
RETURN jsonb_build_object(
'deleted', true,
'plan_key', v_plan_key
);
END;
$$;
CREATE FUNCTION public.dev_list_auth_users(p_limit integer DEFAULT 50) RETURNS TABLE(id uuid, email text, created_at timestamp with time zone)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'auth'
AS $$
begin
-- s?? saas_admin pode ver
if not exists (
select 1
from public.profiles p
where p.id = auth.uid()
and p.role = 'saas_admin'
) then
return;
end if;
return query
select
u.id,
u.email,
u.created_at
from auth.users u
order by u.created_at desc
limit greatest(1, least(coalesce(p_limit, 50), 500));
end;
$$;
CREATE FUNCTION public.dev_list_custom_users() RETURNS TABLE(user_id uuid, email text, created_at timestamp with time zone, global_role text, tenant_role text, tenant_id uuid, password_dev text, kind text)
LANGUAGE sql SECURITY DEFINER
SET search_path TO 'public'
AS $$
with base as (
select
u.id as user_id,
lower(u.email) as email,
u.created_at
from auth.users u
where lower(u.email) not in (
'clinic@agenciapsi.com.br',
'therapist@agenciapsi.com.br',
'patient@agenciapsi.com.br',
'saas@agenciapsi.com.br'
)
),
prof as (
select p.id, p.role as global_role
from public.profiles p
),
last_membership as (
select distinct on (tm.user_id)
tm.user_id,
tm.tenant_id,
tm.role as tenant_role,
tm.created_at
from public.tenant_members tm
where tm.status = 'active'
order by tm.user_id, tm.created_at desc
)
select
b.user_id,
b.email,
b.created_at,
pr.global_role,
lm.tenant_role,
lm.tenant_id,
dc.password_dev,
dc.kind
from base b
left join prof pr on pr.id = b.user_id
left join last_membership lm on lm.user_id = b.user_id
left join public.dev_user_credentials dc on lower(dc.email) = b.email
order by b.created_at desc;
$$;
CREATE FUNCTION public.dev_list_intent_leads() RETURNS TABLE(email text, last_intent_at timestamp with time zone, plan_key text, billing_interval text, status text, tenant_id uuid)
LANGUAGE sql SECURITY DEFINER
SET search_path TO 'public'
AS $$
select
lower(si.email) as email,
max(si.created_at) as last_intent_at,
(array_agg(si.plan_key order by si.created_at desc))[1] as plan_key,
(array_agg(si.interval order by si.created_at desc))[1] as billing_interval,
(array_agg(si.status order by si.created_at desc))[1] as status,
(array_agg(si.tenant_id order by si.created_at desc))[1] as tenant_id
from public.subscription_intents si
where si.email is not null
and not exists (
select 1
from auth.users au
where lower(au.email) = lower(si.email)
)
group by lower(si.email)
order by max(si.created_at) desc;
$$;
CREATE FUNCTION public.dev_public_debug_snapshot() RETURNS TABLE(users_total integer, tenants_total integer, intents_new_total integer, latest_intents jsonb)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
declare
v_latest jsonb;
begin
select jsonb_agg(
jsonb_build_object(
'created_at', si.created_at,
'email_masked',
regexp_replace(lower(si.email), '(^.).*(@.*$)', '\1***\2'),
'plan_key', si.plan_key,
'status', si.status
)
order by si.created_at desc
)
into v_latest
from (
select si.*
from public.subscription_intents si
where si.email is not null
order by si.created_at desc
limit 5
) si;
return query
select
(select count(*)::int from auth.users) as users_total,
(select count(*)::int from public.tenants) as tenants_total,
(select count(*)::int from public.subscription_intents where status = 'new') as intents_new_total,
coalesce(v_latest, '[]'::jsonb) as latest_intents;
end;
$_$;
CREATE FUNCTION public.dev_set_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.ensure_personal_tenant() RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_uid uuid;
v_existing uuid;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT tm.tenant_id INTO v_existing
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = v_uid
AND tm.status = 'active'
AND t.kind IN ('therapist', 'saas')
ORDER BY tm.created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
RETURN public.provision_account_tenant(v_uid, 'therapist');
END;
$$;
CREATE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid;
v_existing uuid;
v_tenant uuid;
v_email text;
v_name text;
begin
v_uid := p_user_id;
if v_uid is null then
raise exception 'Missing user id';
end if;
-- s?? considera tenant pessoal (kind='saas')
select tm.tenant_id
into v_existing
from public.tenant_members tm
join public.tenants t on t.id = tm.tenant_id
where tm.user_id = v_uid
and tm.status = 'active'
and t.kind = 'saas'
order by tm.created_at desc
limit 1;
if v_existing is not null then
return v_existing;
end if;
select email into v_email
from auth.users
where id = v_uid;
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
insert into public.tenants (name, kind, created_at)
values (v_name || ' (Pessoal)', 'saas', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
return v_tenant;
end;
$$;
CREATE FUNCTION public.export_patient_data(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_patient RECORD;
v_tenant_id UUID;
v_caller UUID;
v_is_member BOOLEAN;
v_result JSONB;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
END IF;
-- carrega paciente
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
END IF;
v_tenant_id := v_patient.tenant_id;
-- verifica se caller e membro ativo do tenant do paciente
SELECT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
) OR public.is_saas_admin() INTO v_is_member;
IF NOT v_is_member THEN
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
END IF;
-- monta o payload
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object(
'generated_at', now(),
'generated_by', v_caller,
'tenant_id', v_tenant_id,
'patient_id', p_patient_id,
'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
'controller', 'AgenciaPSI - Clinica responsavel',
'format_version', '1.0'
),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((
SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
FROM public.patient_contacts pc
WHERE pc.patient_id = p_patient_id
), '[]'::jsonb),
'contatos_apoio', COALESCE((
SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
FROM public.patient_support_contacts psc
WHERE psc.patient_id = p_patient_id
), '[]'::jsonb),
'historico_status', COALESCE((
SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
FROM public.patient_status_history psh
WHERE psh.patient_id = p_patient_id
), '[]'::jsonb),
'timeline', COALESCE((
SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
FROM public.patient_timeline pt
WHERE pt.patient_id = p_patient_id
), '[]'::jsonb),
'descontos', COALESCE((
SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
FROM public.patient_discounts pd
WHERE pd.patient_id = p_patient_id
), '[]'::jsonb),
'eventos_agenda', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', ae.id,
'tipo', ae.tipo,
'inicio_em', ae.inicio_em,
'fim_em', ae.fim_em,
'status', ae.status,
'observacoes', ae.observacoes,
'created_at', ae.created_at
) ORDER BY ae.inicio_em
)
FROM public.agenda_eventos ae
WHERE ae.patient_id = p_patient_id
), '[]'::jsonb),
'registros_financeiros', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', fr.id,
'amount', fr.amount,
'discount_amount', fr.discount_amount,
'final_amount', fr.final_amount,
'status', fr.status,
'due_date', fr.due_date,
'paid_at', fr.paid_at,
'payment_method', fr.payment_method,
'notes', fr.notes,
'created_at', fr.created_at
) ORDER BY fr.created_at
)
FROM public.financial_records fr
WHERE fr.patient_id = p_patient_id
), '[]'::jsonb),
'documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', d.id,
'nome_original', d.nome_original,
'tipo_documento', d.tipo_documento,
'categoria', d.categoria,
'descricao', d.descricao,
'mime_type', d.mime_type,
'tamanho_bytes', d.tamanho_bytes,
'status_revisao', d.status_revisao,
'visibilidade', d.visibilidade,
'uploaded_at', d.uploaded_at,
'created_at', d.created_at
) ORDER BY d.created_at
)
FROM public.documents d
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
), '[]'::jsonb),
'notificacoes_enviadas', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', nl.id,
'channel', nl.channel,
'template_key', nl.template_key,
'recipient_address', nl.recipient_address,
'status', nl.status,
'sent_at', nl.sent_at,
'delivered_at', nl.delivered_at,
'read_at', nl.read_at,
'failure_reason', nl.failure_reason,
'created_at', nl.created_at
) ORDER BY nl.created_at
)
FROM public.notification_logs nl
WHERE nl.patient_id = p_patient_id
), '[]'::jsonb),
'audit_trail', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', al.id,
'action', al.action,
'entity_type', al.entity_type,
'changed_fields', al.changed_fields,
'user_id', al.user_id,
'created_at', al.created_at
) ORDER BY al.created_at
)
FROM public.audit_logs al
WHERE al.tenant_id = v_tenant_id
AND al.entity_type = 'patients'
AND al.entity_id = p_patient_id::text
), '[]'::jsonb),
'acessos_a_documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', dal.id,
'documento_id', dal.documento_id,
'acao', dal.acao,
'user_id', dal.user_id,
'acessado_em', dal.acessado_em
) ORDER BY dal.acessado_em
)
FROM public.document_access_logs dal
WHERE dal.documento_id IN (
SELECT id FROM public.documents WHERE patient_id = p_patient_id
)
), '[]'::jsonb),
'grupos', COALESCE((
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
FROM public.patient_group_patient pgp
WHERE pgp.patient_id = p_patient_id
), '[]'::jsonb),
'tags', COALESCE((
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
FROM public.patient_patient_tag ppt
WHERE ppt.patient_id = p_patient_id
), '[]'::jsonb)
);
-- registra o export como evento auditavel
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields, metadata
) VALUES (
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
NULL, NULL, ARRAY['__lgpd_export__'],
jsonb_build_object(
'action_kind', 'lgpd_export',
'lgpd_basis', 'Art. 18, II',
'patient_name', v_patient.nome_completo
)
);
RETURN v_result;
END;
$$;
CREATE FUNCTION public.fanout_inbound_message_to_notifications() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_target_user UUID;
v_title TEXT;
v_detail TEXT;
v_initials TEXT;
v_deeplink TEXT;
v_patient_name TEXT;
v_payload JSONB;
BEGIN
-- so processa inbound
IF NEW.direction <> 'inbound' THEN
RETURN NEW;
END IF;
-- busca nome do paciente (se vinculado)
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
END IF;
-- titulo e detalhes
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
-- iniciais
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name, 1)) ||
COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
ELSE
v_initials := '?';
END IF;
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object(
'title', v_title,
'detail', v_detail,
'avatar_initials', v_initials,
'deeplink', v_deeplink,
'channel', NEW.channel,
'conversation_message_id', NEW.id,
'patient_id', NEW.patient_id,
'from_number', NEW.from_number
);
-- ─── decide destinatarios ─────────────────────────────────────────────
-- Caso 1: paciente vinculado e tem responsible_member_id
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM public.patients p
JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id
AND tm.status = 'active'
LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = NEW.tenant_id
AND tm.status = 'active'
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
RETURN NEW;
END;
$$;
CREATE FUNCTION public.faq_votar(faq_id uuid) RETURNS void
LANGUAGE sql SECURITY DEFINER
AS $$
update public.saas_faq
set votos = votos + 1,
updated_at = now()
where id = faq_id
and ativo = true;
$$;
CREATE FUNCTION public.financial_records_inject_tenant() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
SELECT tm.tenant_id INTO NEW.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
ORDER BY tm.created_at DESC
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
r record;
begin
for r in
select distinct s.user_id as owner_id
from public.subscriptions s
where s.status = 'active'
and s.user_id is not null
loop
perform public.rebuild_owner_entitlements(r.owner_id);
end loop;
end;
$$;
CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
LANGUAGE plpgsql
AS $$
declare
v_count int;
begin
if new.ativo is false then
return new;
end if;
select count(*) into v_count
from public.agenda_regras_semanais r
where r.owner_id = new.owner_id
and r.dia_semana = new.dia_semana
and r.ativo is true
and (tg_op = 'INSERT' or r.id <> new.id)
and (new.hora_inicio < r.hora_fim and new.hora_fim > r.hora_inicio);
if v_count > 0 then
raise exception 'Janela sobreposta: j?? existe uma regra ativa nesse intervalo.';
end if;
return new;
end;
$$;
CREATE FUNCTION public.fn_document_signature_timeline() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_id,
'documento_assinado',
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
'green',
'documento',
NEW.documento_id,
NEW.signatario_id,
NEW.assinado_em
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.fn_documents_timeline_insert() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_id,
'documento_adicionado',
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
'blue',
'documento',
NEW.id,
NEW.uploaded_by,
NEW.uploaded_at
);
RETURN NEW;
END;
$$;
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$$;
CREATE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid) RETURNS text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT number FROM public.contact_phones
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
-- ?????? Valida p_group_by antes de executar ??????????????????????????????????????????????????????????????????????????????????????????????????????
-- (lan??a erro se valor inv??lido; plpgsql seria necess??rio para isso em SQL puro,
-- ent??o usamos um CTE de valida????o com CASE WHEN para retornar vazio em vez de erro)
WITH base AS (
SELECT
fr.type,
fr.amount,
fr.final_amount,
fr.status,
fr.deleted_at,
-- Chave de agrupamento calculada conforme p_group_by
CASE p_group_by
WHEN 'month' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'YYYY-MM'
)
WHEN 'week' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'IYYY-"W"IW'
)
WHEN 'category' THEN COALESCE(fr.category_id::TEXT, fr.category, 'sem_categoria')
WHEN 'patient' THEN COALESCE(fr.patient_id::TEXT, 'sem_paciente')
ELSE NULL -- group_by inv??lido ??? group_key NULL ??? retorno vazio
END AS gkey,
-- Label leg??vel (enriquecido via JOIN abaixo quando poss??vel)
CASE p_group_by
WHEN 'month' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'YYYY-MM'
)
WHEN 'week' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'IYYY-"W"IW'
)
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::TEXT, 'Sem paciente')
ELSE NULL
END AS glabel
FROM public.financial_records fr
LEFT JOIN public.financial_categories fc
ON fc.id = fr.category_id
LEFT JOIN public.patients p
ON p.id = fr.patient_id
WHERE fr.owner_id = p_owner_id
AND fr.deleted_at IS NULL
AND COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE)
BETWEEN p_start_date AND p_end_date
)
SELECT
gkey AS group_key,
glabel AS group_label,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
AS total_receitas,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
AS total_despesas,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
- COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
AS saldo,
COALESCE(SUM(final_amount) FILTER (WHERE status = 'pending'), 0) AS total_pendente,
COALESCE(SUM(final_amount) FILTER (WHERE status = 'overdue'), 0) AS total_overdue,
COUNT(*) AS count_records
FROM base
WHERE gkey IS NOT NULL -- descarta p_group_by inv??lido
GROUP BY gkey, glabel
ORDER BY gkey ASC;
$$;
CREATE FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
-- Receitas pagas no per??odo
COALESCE(SUM(amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0) AS total_receitas,
-- Despesas pagas no per??odo
COALESCE(SUM(amount) FILTER (
WHERE type = 'despesa' AND status = 'paid'
), 0) AS total_despesas,
-- Tudo pendente ou vencido (receitas + despesas)
COALESCE(SUM(amount) FILTER (
WHERE status IN ('pending', 'overdue')
), 0) AS total_pendente,
-- Saldo l??quido (receitas pagas ??? despesas pagas)
COALESCE(SUM(amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0)
- COALESCE(SUM(amount) FILTER (
WHERE type = 'despesa' AND status = 'paid'
), 0) AS saldo_liquido,
-- Total repassado ?? cl??nica (apenas receitas pagas)
COALESCE(SUM(clinic_fee_amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0) AS total_repasse,
-- Contadores (excluindo soft-deleted)
COUNT(*) FILTER (WHERE type = 'receita' AND deleted_at IS NULL) AS count_receitas,
COUNT(*) FILTER (WHERE type = 'despesa' AND deleted_at IS NULL) AS count_despesas
FROM public.financial_records
WHERE owner_id = p_owner_id
AND deleted_at IS NULL
AND EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year
AND EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month;
$$;
CREATE FUNCTION public.get_my_email() RETURNS text
LANGUAGE sql SECURITY DEFINER
SET search_path TO 'public', 'auth'
AS $$
select lower(email)
from auth.users
where id = auth.uid();
$$;
CREATE FUNCTION public.get_patient_intake_invite_info(p_token text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_token_clean text;
v_invite RECORD;
v_result jsonb;
BEGIN
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
IF v_token_clean IS NULL THEN
RETURN jsonb_build_object('error', 'missing-token');
END IF;
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
INTO v_invite
FROM public.patient_invites pi
WHERE pi.token = v_token_clean
LIMIT 1;
IF v_invite.owner_id IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.active IS DISTINCT FROM true THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
SELECT jsonb_build_object(
'therapist', jsonb_build_object(
'display_name', coalesce(
nullif(trim(p.full_name), ''),
nullif(trim(p.nickname), ''),
'Profissional'
),
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
'bio', nullif(trim(coalesce(p.bio, '')), ''),
'phone', nullif(trim(coalesce(p.phone, '')), ''),
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
),
'clinic', CASE
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
'email', nullif(trim(coalesce(cp.email, '')), ''),
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
'site', nullif(trim(coalesce(cp.site, '')), ''),
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
'state', nullif(trim(coalesce(cp.estado, '')), ''),
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
'number', nullif(trim(coalesce(cp.numero, '')), ''),
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
)
ELSE NULL
END
)
INTO v_result
FROM public.profiles p
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
WHERE p.id = v_invite.owner_id
LIMIT 1;
IF v_result IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;
CREATE FUNCTION public.get_patient_session_counts(p_patient_ids uuid[]) RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamp with time zone)
LANGUAGE sql SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
ae.patient_id,
COUNT(*)::int AS session_count,
MAX(ae.inicio_em) AS last_session_at
FROM public.agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
AND ae.tenant_id IN (
SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
)
GROUP BY ae.patient_id;
$$;
CREATE FUNCTION public.get_twilio_config() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
cfg saas_twilio_config%ROWTYPE;
BEGIN
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
END IF;
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'account_sid', NULL,
'whatsapp_webhook_url', NULL,
'usd_brl_rate', 5.5,
'margin_multiplier', 1.4
);
END IF;
RETURN jsonb_build_object(
'account_sid', cfg.account_sid,
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
'usd_brl_rate', cfg.usd_brl_rate,
'margin_multiplier', cfg.margin_multiplier,
'notes', cfg.notes,
'updated_at', cfg.updated_at,
'updated_by', cfg.updated_by
);
END;
$$;
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.account_type <> 'free' AND NEW.account_type IS DISTINCT FROM OLD.account_type THEN
RAISE EXCEPTION 'account_type ?? imut??vel ap??s escolha (atual: "%" para tentativa: "%"). Para mudar de perfil, crie uma nova conta.', OLD.account_type, NEW.account_type
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.guard_locked_commitment() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if (old.is_locked = true) then
if (tg_op = 'DELETE') then
raise exception 'Compromisso bloqueado n??o pode ser exclu??do.';
end if;
if (tg_op = 'UPDATE') then
if (new.active = false) then
raise exception 'Compromisso bloqueado n??o pode ser desativado.';
end if;
-- trava renomear (mant??m o "Sess??o" sempre igual)
if (new.name is distinct from old.name) then
raise exception 'Compromisso bloqueado n??o pode ser renomeado.';
end if;
-- se quiser travar descri????o tamb??m, descomente:
-- if (new.description is distinct from old.description) then
-- raise exception 'Compromisso bloqueado n??o pode alterar descri????o.';
-- end if;
end if;
end if;
return new;
end;
$$;
CREATE FUNCTION public.guard_no_change_core_plan_key() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
and new.key is distinct from old.key then
raise exception 'N??o ?? permitido alterar a key do plano padr??o (%).', old.key
using errcode = 'P0001';
end if;
return new;
end $$;
CREATE FUNCTION public.guard_no_change_plan_target() RETURNS trigger
LANGUAGE plpgsql
AS $$
declare
v_bypass text;
begin
-- bypass controlado por sess??o/transa????o:
-- s?? passa se app.plan_migration_bypass = '1'
v_bypass := current_setting('app.plan_migration_bypass', true);
if v_bypass = '1' then
return new;
end if;
-- comportamento original (bloqueia qualquer mudan??a)
if new.target is distinct from old.target then
raise exception 'N??o ?? permitido alterar target do plano (%) de % para %.',
old.key, old.target, new.target
using errcode = 'P0001';
end if;
return new;
end
$$;
CREATE FUNCTION public.guard_no_delete_core_plans() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then
raise exception 'Plano padr??o (%) n??o pode ser removido.', old.key
using errcode = 'P0001';
end if;
return old;
end $$;
CREATE FUNCTION public.guard_patient_cannot_own_tenant() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_account_type text;
BEGIN
SELECT account_type INTO v_account_type
FROM public.profiles
WHERE id = NEW.user_id;
IF v_account_type = 'patient' AND NEW.role IN ('tenant_admin', 'therapist') THEN
RAISE EXCEPTION 'Usu??rio com perfil "patient" n??o pode ser propriet??rio ou terapeuta de um tenant. Se tornou profissional? Crie uma nova conta.'
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.guard_tenant_kind_immutable() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.kind IS DISTINCT FROM OLD.kind THEN
RAISE EXCEPTION 'tenants.kind ?? imut??vel ap??s cria????o. Tentativa de alterar "%" para "%".', OLD.kind, NEW.kind
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
INSERT INTO public.profiles (id, role, account_type)
VALUES (NEW.id, 'portal_user', 'free')
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.handle_new_user_create_personal_tenant() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- Desabilitado. Tenant criado no onboarding via provision_account_tenant().
RETURN NEW;
END;
$$;
CREATE FUNCTION public.has_feature(p_owner_id uuid, p_feature_key text) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select exists (
select 1
from public.owner_feature_entitlements e
where e.owner_id = p_owner_id
and e.feature_key = p_feature_key
);
$$;
CREATE FUNCTION public.is_clinic_tenant(_tenant_id uuid) RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM public.tenants t
WHERE t.id = _tenant_id
AND t.kind IN ('clinic', 'clinic_coworking', 'clinic_reception', 'clinic_full')
);
$$;
CREATE FUNCTION public.is_saas_admin() RETURNS boolean
LANGUAGE sql STABLE
AS $$
select exists (
select 1 from public.saas_admins sa
where sa.user_id = auth.uid()
);
$$;
CREATE FUNCTION public.is_tenant_admin(p_tenant_id uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
select exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.role = 'tenant_admin'
and tm.status = 'active'
);
$$;
CREATE FUNCTION public.is_tenant_member(_tenant_id uuid) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select exists (
select 1
from public.tenant_members m
where m.tenant_id = _tenant_id
and m.user_id = auth.uid()
and m.status = 'active'
);
$$;
CREATE FUNCTION public.is_therapist_tenant(_tenant_id uuid) RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM public.tenants t
WHERE t.id = _tenant_id AND t.kind = 'therapist'
);
$$;
CREATE FUNCTION public.issue_patient_invite() RETURNS text
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_token text;
v_existing text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
SELECT token
INTO v_existing
FROM public.patient_invites
WHERE owner_id = v_uid
AND active = true
ORDER BY created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
v_token := replace(gen_random_uuid()::text, '-', '');
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_token, true);
RETURN v_token;
END;
$$;
CREATE FUNCTION public.jwt_email() RETURNS text
LANGUAGE sql STABLE
AS $$
select nullif(lower(current_setting('request.jwt.claim.email', true)), '');
$$;
CREATE FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer DEFAULT NULL::integer, p_month integer DEFAULT NULL::integer, p_type text DEFAULT NULL::text, p_status text DEFAULT NULL::text, p_patient_id uuid DEFAULT NULL::uuid, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) RETURNS SETOF public.financial_records
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT *
FROM public.financial_records
WHERE owner_id = p_owner_id
AND deleted_at IS NULL
AND (p_type IS NULL OR type::TEXT = p_type)
AND (p_status IS NULL OR status = p_status)
AND (p_patient_id IS NULL OR patient_id = p_patient_id)
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year)
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month)
ORDER BY COALESCE(paid_at, due_date::TIMESTAMPTZ, created_at) DESC
LIMIT p_limit
OFFSET p_offset;
$$;
CREATE FUNCTION public.log_audit_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_tenant_id UUID;
v_entity_id TEXT;
v_old JSONB;
v_new JSONB;
v_changed TEXT[];
v_heavy_fields TEXT[] := ARRAY[
'content', 'content_html', 'content_json', 'raw_data',
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
];
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_entity_id := OLD.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := NULL;
v_new := to_jsonb(NEW) - v_heavy_fields;
ELSE -- UPDATE
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := to_jsonb(NEW) - v_heavy_fields;
-- calcular campos realmente alterados
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
-- se nada mudou, ignora
IF v_changed IS NULL THEN
RETURN NEW;
END IF;
-- se mudou apenas campos de ruido (ex: updated_at), ignora
IF v_changed <@ v_noise_fields THEN
RETURN NEW;
END IF;
END IF;
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields
) VALUES (
v_tenant_id,
auth.uid(),
TG_TABLE_NAME,
v_entity_id,
lower(TG_OP),
v_old,
v_new,
v_changed
);
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) RETURNS SETOF public.financial_records
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_record public.financial_records%ROWTYPE;
BEGIN
-- Garante que o registro pertence ao usu??rio autenticado (RLS n??o aplica em SECURITY DEFINER)
SELECT * INTO v_record
FROM public.financial_records
WHERE id = p_financial_record_id
AND owner_id = auth.uid()
AND deleted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'Registro financeiro n??o encontrado ou sem permiss??o.';
END IF;
IF v_record.status NOT IN ('pending', 'overdue') THEN
RAISE EXCEPTION 'Apenas cobran??as pendentes ou vencidas podem ser marcadas como pagas.';
END IF;
UPDATE public.financial_records
SET status = 'paid',
paid_at = NOW(),
payment_method = p_payment_method,
updated_at = NOW()
WHERE id = p_financial_record_id
RETURNING * INTO v_record;
RETURN NEXT v_record;
END;
$$;
CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_payout public.therapist_payouts%ROWTYPE;
BEGIN
-- Busca o payout
SELECT * INTO v_payout
FROM public.therapist_payouts
WHERE id = p_payout_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Repasse n??o encontrado: %', p_payout_id;
END IF;
-- Verifica permiss??o: apenas tenant_admin do tenant do repasse
IF NOT public.is_tenant_admin(v_payout.tenant_id) THEN
RAISE EXCEPTION 'Apenas o administrador da cl??nica pode marcar repasses como pagos.';
END IF;
-- Verifica status
IF v_payout.status <> 'pending' THEN
RAISE EXCEPTION
'Repasse j?? est?? com status ''%''. Apenas repasses pendentes podem ser pagos.',
v_payout.status;
END IF;
-- Atualiza
UPDATE public.therapist_payouts
SET
status = 'paid',
paid_at = NOW(),
updated_at = NOW()
WHERE id = p_payout_id
RETURNING * INTO v_payout;
RETURN v_payout;
END;
$$;
CREATE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text) RETURNS uuid
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $$
DECLARE
v_normalized TEXT;
v_patient_id UUID;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
RETURN NULL;
END IF;
-- prioridade: telefone principal, depois alternativo, depois responsavel
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_alternativo) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
LIMIT 1;
RETURN v_patient_id;
END;
$$;
CREATE FUNCTION public.my_tenants() RETURNS TABLE(tenant_id uuid, role text, status text, kind text)
LANGUAGE sql STABLE
AS $$
select
tm.tenant_id,
tm.role,
tm.status,
t.kind
from public.tenant_members tm
join public.tenants t on t.id = tm.tenant_id
where tm.user_id = auth.uid();
$$;
CREATE FUNCTION public.normalize_phone_br(p_phone text) RETURNS text
LANGUAGE plpgsql IMMUTABLE
AS $$
DECLARE
v_digits TEXT;
BEGIN
IF p_phone IS NULL THEN RETURN NULL; END IF;
-- remove tudo que nao seja digito
v_digits := regexp_replace(p_phone, '\D', '', 'g');
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
v_digits := substr(v_digits, 3);
END IF;
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
IF length(v_digits) > 11 THEN
v_digits := right(v_digits, 11);
END IF;
RETURN v_digits;
END;
$$;
CREATE FUNCTION public.notice_track_click(p_notice_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
begin
update public.global_notices
set clicks_count = clicks_count + 1
where id = p_notice_id;
end;
$$;
CREATE FUNCTION public.notice_track_view(p_notice_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
begin
update public.global_notices
set views_count = views_count + 1
where id = p_notice_id;
end;
$$;
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'new' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_patient',
NEW.id,
'patient_intake_requests',
jsonb_build_object(
'title', 'Novo cadastro externo',
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
'deeplink', '/therapist/patients/cadastro/recebidos',
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$ BEGIN IF NEW.status = 'pendente' THEN
INSERT INTO public.notifications ( owner_id, tenant_id, type, ref_id, ref_table, payload ) VALUES (
NEW.owner_id, NEW.tenant_id,
'new_scheduling', NEW.id, 'agendador_solicitacoes', jsonb_build_object( 'title', 'Nova solicita????o de agendamento', 'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || ' ??? ' || COALESCE(NEW.tipo, ''), 'deeplink', '/therapist/agendamentos-recebidos', 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1)) ) ); END IF; RETURN NEW; END; $$;
CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_nome text;
BEGIN
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
SELECT nome_completo
INTO v_nome
FROM public.patients
WHERE id = NEW.patient_id
LIMIT 1;
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'session_status',
NEW.id,
'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sess??o cancelada' END,
'detail', COALESCE(v_nome, 'Paciente') || ' ??? ' || to_char(NEW.inicio_em, 'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.on_new_user_seed_patient_groups() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
PERFORM public.seed_default_patient_groups(NEW.id);
RETURN NEW;
END;
$$;
CREATE FUNCTION public.patients_validate_member_consistency() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_tenant_responsible uuid;
v_tenant_therapist uuid;
BEGIN
-- responsible_member sempre deve existir e ser do tenant
SELECT tenant_id INTO v_tenant_responsible
FROM public.tenant_members
WHERE id = NEW.responsible_member_id;
IF v_tenant_responsible IS NULL THEN
RAISE EXCEPTION 'Responsible member not found';
END IF;
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id is required';
END IF;
IF v_tenant_responsible <> NEW.tenant_id THEN
RAISE EXCEPTION 'Responsible member must belong to the same tenant';
END IF;
-- therapist scope: therapist_member_id deve existir e ser do mesmo tenant
IF NEW.patient_scope = 'therapist' THEN
IF NEW.therapist_member_id IS NULL THEN
RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist';
END IF;
SELECT tenant_id INTO v_tenant_therapist
FROM public.tenant_members
WHERE id = NEW.therapist_member_id;
IF v_tenant_therapist IS NULL THEN
RAISE EXCEPTION 'Therapist member not found';
END IF;
IF v_tenant_therapist <> NEW.tenant_id THEN
RAISE EXCEPTION 'Therapist member must belong to the same tenant';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.patients_validate_responsible_member_tenant() RETURNS trigger
LANGUAGE plpgsql
AS $$
declare
m_tenant uuid;
begin
select tenant_id into m_tenant
from public.tenant_members
where id = new.responsible_member_id;
if m_tenant is null then
raise exception 'Responsible member not found';
end if;
if new.tenant_id is null then
raise exception 'tenant_id is required';
end if;
if m_tenant <> new.tenant_id then
raise exception 'Responsible member must belong to the same tenant';
end if;
return new;
end;
$$;
CREATE FUNCTION public.populate_notification_queue() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.notification_queue (
tenant_id, owner_id, agenda_evento_id, patient_id,
channel, template_key, schedule_key,
resolved_vars, recipient_address,
scheduled_at, idempotency_key
)
SELECT
ae.tenant_id,
ae.owner_id,
ae.id AS agenda_evento_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 public.agenda_eventos ae
JOIN public.patients p ON p.id = ae.patient_id
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id
JOIN public.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 public.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' AS channel WHERE ns.email_enabled AND nc.channel = 'email'
UNION ALL
SELECT 'sms' AS channel WHERE ns.sms_enabled AND nc.channel = 'sms'
) ch
LEFT JOIN public.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.inicio_em > now()
AND ae.inicio_em <= now() + interval '48 hours'
AND ae.status NOT IN ('cancelado', 'faltou')
AND CASE ch.channel
WHEN 'whatsapp' THEN COALESCE(p.telefone, '') != ''
WHEN 'sms' THEN COALESCE(p.telefone, '') != ''
WHEN 'email' THEN COALESCE(p.email_principal, '') != ''
END
AND CASE ch.channel
WHEN 'whatsapp' THEN COALESCE(np.whatsapp_opt_in, true)
WHEN 'email' THEN COALESCE(np.email_opt_in, true)
WHEN 'sms' THEN COALESCE(np.sms_opt_in, false)
END
AND EXISTS (
SELECT 1 FROM public.profiles tp
WHERE tp.id = ae.owner_id
AND COALESCE(tp.notify_reminders, true) = true
)
ON CONFLICT (idempotency_key) DO NOTHING;
END;
$$;
CREATE FUNCTION public.prevent_promoting_to_system() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if new.is_system = true and old.is_system is distinct from true then
raise exception 'N??o ?? permitido transformar um grupo comum em grupo do sistema.';
end if;
return new;
end;
$$;
CREATE FUNCTION public.prevent_saas_membership() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.profiles
WHERE id = NEW.user_id
AND role = 'saas_admin'
) THEN
RAISE EXCEPTION 'SaaS admin cannot belong to tenant';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.prevent_system_group_changes() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
-- Se for grupo do sistema, regras r??gidas:
if old.is_system = true then
-- nunca pode deletar
if tg_op = 'DELETE' then
raise exception 'Grupos padr??o do sistema n??o podem ser alterados ou exclu??dos.';
end if;
if tg_op = 'UPDATE' then
-- permite SOMENTE mudar tenant_id e/ou updated_at
-- qualquer mudan??a de conte??do permanece proibida
if
new.nome is distinct from old.nome or
new.descricao is distinct from old.descricao or
new.cor is distinct from old.cor or
new.is_active is distinct from old.is_active or
new.is_system is distinct from old.is_system or
new.owner_id is distinct from old.owner_id or
new.therapist_id is distinct from old.therapist_id or
new.created_at is distinct from old.created_at
then
raise exception 'Grupos padr??o do sistema n??o podem ser alterados ou exclu??dos.';
end if;
-- chegou aqui: s?? tenant_id/updated_at mudaram -> ok
return new;
end if;
end if;
-- n??o-system: deixa passar
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
CREATE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_tenant_id uuid;
v_account_type text;
v_name text;
BEGIN
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
RAISE EXCEPTION 'kind inv??lido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
USING ERRCODE = 'P0001';
END IF;
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
IF EXISTS (
SELECT 1
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = p_user_id
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND t.kind = p_kind
) THEN
RAISE EXCEPTION 'Usu??rio j?? possui um tenant do tipo "%".', p_kind
USING ERRCODE = 'P0001';
END IF;
v_name := COALESCE(
NULLIF(TRIM(p_name), ''),
(
SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
FROM public.profiles pr
JOIN auth.users au ON au.id = pr.id
WHERE pr.id = p_user_id
),
'Conta'
);
INSERT INTO public.tenants (name, kind, created_at)
VALUES (v_name, p_kind, now())
RETURNING id INTO v_tenant_id;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
UPDATE public.profiles
SET account_type = v_account_type
WHERE id = p_user_id;
PERFORM public.seed_determined_commitments(v_tenant_id);
RETURN v_tenant_id;
END;
$$;
CREATE FUNCTION public.reactivate_subscription(p_subscription_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_sub public.subscriptions;
v_owner_type text;
v_owner_ref uuid;
begin
select *
into v_sub
from public.subscriptions
where id = p_subscription_id
for update;
if not found then
raise exception 'Subscription n??o encontrada';
end if;
if v_sub.status = 'active' then
return v_sub;
end if;
if v_sub.tenant_id is not null then
v_owner_type := 'clinic';
v_owner_ref := v_sub.tenant_id;
elsif v_sub.user_id is not null then
v_owner_type := 'therapist';
v_owner_ref := v_sub.user_id;
else
v_owner_type := null;
v_owner_ref := null;
end if;
update public.subscriptions
set status = 'active',
cancel_at_period_end = false,
updated_at = now()
where id = p_subscription_id
returning * into v_sub;
insert into public.subscription_events(
subscription_id,
owner_id,
owner_type,
owner_ref,
event_type,
old_plan_id,
new_plan_id,
created_by,
reason,
source,
metadata
)
values (
v_sub.id,
v_owner_ref,
v_owner_type,
v_owner_ref,
'reactivated',
v_sub.plan_id,
v_sub.plan_id,
auth.uid(),
'Reativa????o manual via admin',
'admin_panel',
jsonb_build_object('previous_status', 'canceled')
);
if v_owner_ref is not null then
insert into public.entitlements_invalidation(owner_id, changed_at)
values (v_owner_ref, now())
on conflict (owner_id)
do update set changed_at = excluded.changed_at;
end if;
return v_sub;
end;
$$;
CREATE FUNCTION public.rebuild_owner_entitlements(p_owner_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_plan_id uuid;
begin
-- Plano ativo do owner (owner = subscriptions.user_id)
select s.plan_id
into v_plan_id
from public.subscriptions s
where s.user_id = p_owner_id
and s.status = 'active'
order by s.created_at desc
limit 1;
-- Sempre zera entitlements do owner (rebuild)
delete from public.owner_feature_entitlements e
where e.owner_id = p_owner_id;
-- Se n??o tem assinatura ativa, acabou
if v_plan_id is null then
return;
end if;
-- Recria entitlements esperados pelo plano
insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list)
select
p_owner_id as owner_id,
f.key as feature_key,
array['plan'::text] as sources,
'{}'::jsonb as limits_list
from public.plan_features pf
join public.features f on f.id = pf.feature_id
where pf.plan_id = v_plan_id;
end;
$$;
CREATE FUNCTION public.record_submission_attempt(p_endpoint text, p_ip_hash text, p_success boolean, p_blocked_by text DEFAULT NULL::text, p_error_code text DEFAULT NULL::text, p_error_msg text DEFAULT NULL::text, p_user_agent text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$$;
CREATE FUNCTION public.revoke_support_session(p_token text) RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_admin_id uuid;
v_role text;
BEGIN
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'N??o autenticado.' USING ERRCODE = 'P0001';
END IF;
SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
END IF;
DELETE FROM public.support_sessions
WHERE token = p_token
AND admin_id = v_admin_id;
RETURN FOUND;
END;
$$;
CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_uid uuid;
v_id uuid;
begin
-- pega o usu??rio logado
v_uid := auth.uid();
if v_uid is null then
raise exception 'Usu??rio n??o autenticado';
end if;
-- desativa tokens antigos ativos do usu??rio
update public.patient_invites
set active = false
where owner_id = v_uid
and active = true;
-- cria novo token
insert into public.patient_invites (owner_id, token, active)
values (v_uid, p_new_token, true)
returning id into v_id;
return v_id;
end;
$$;
CREATE FUNCTION public.rotate_patient_invite_token_v2() RETURNS text
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_new_token text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
v_new_token := replace(gen_random_uuid()::text, '-', '');
-- Resolve tenant_id do usuário (active)
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
-- Desativa tokens ativos anteriores
UPDATE public.patient_invites
SET active = false
WHERE owner_id = v_uid
AND active = true;
-- Insere novo
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_new_token, true);
RETURN v_new_token;
END;
$$;
CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid := auth.uid();
v_voto_antigo boolean;
begin
if v_uid is null then
raise exception 'N??o autenticado';
end if;
-- Verifica se j?? votou
select util into v_voto_antigo
from public.saas_doc_votos
where doc_id = p_doc_id and user_id = v_uid;
if found then
-- J?? votou igual ??? cancela o voto (toggle)
if v_voto_antigo = p_util then
delete from public.saas_doc_votos
where doc_id = p_doc_id and user_id = v_uid;
update public.saas_docs set
votos_util = greatest(0, votos_util - (case when p_util then 1 else 0 end)),
votos_nao_util = greatest(0, votos_nao_util - (case when not p_util then 1 else 0 end)),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'removido', 'util', null);
else
-- Mudou de voto
update public.saas_doc_votos set util = p_util, updated_at = now()
where doc_id = p_doc_id and user_id = v_uid;
update public.saas_docs set
votos_util = greatest(0, votos_util + (case when p_util then 1 else -1 end)),
votos_nao_util = greatest(0, votos_nao_util + (case when not p_util then 1 else -1 end)),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'atualizado', 'util', p_util);
end if;
else
-- Primeiro voto
insert into public.saas_doc_votos (doc_id, user_id, util)
values (p_doc_id, v_uid, p_util);
update public.saas_docs set
votos_util = votos_util + (case when p_util then 1 else 0 end),
votos_nao_util = votos_nao_util + (case when not p_util then 1 else 0 end),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'registrado', 'util', p_util);
end if;
end;
$$;
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- Bloqueia se houver hist??rico
IF NOT public.can_delete_patient(p_patient_id) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'has_history',
'message', 'Este paciente possui hist??rico cl??nico ou financeiro e n??o pode ser removido. Voc?? pode desativar ou arquivar o paciente.'
);
END IF;
-- Verifica ownership via RLS (owner_id ou responsible_member_id)
IF NOT EXISTS (
SELECT 1 FROM public.patients
WHERE id = p_patient_id
AND (
owner_id = auth.uid()
OR responsible_member_id IN (
SELECT id FROM public.tenant_members WHERE user_id = auth.uid()
)
)
) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'forbidden',
'message', 'Sem permiss??o para excluir este paciente.'
);
END IF;
DELETE FROM public.patients WHERE id = p_patient_id;
RETURN jsonb_build_object('ok', true);
END;
$$;
CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text
LANGUAGE plpgsql IMMUTABLE
AS $$ DECLARE digits text;
BEGIN
digits := regexp_replace(COALESCE(raw_phone, ''), '[^0-9]', '', 'g');
IF digits = '' THEN RETURN ''; END IF;
IF length(digits) = 10 OR length(digits) = 11 THEN
digits := '55' || digits;
END IF;
RETURN digits;
END; $$;
CREATE FUNCTION public.search_global(p_q text, p_scope text[] DEFAULT NULL::text[], p_limit integer DEFAULT 8) RETURNS jsonb
LANGUAGE plpgsql STABLE
SET search_path TO 'public', 'pg_temp'
AS $_$
DECLARE
v_q text;
v_pattern text;
v_limit int;
v_patients jsonb := '[]'::jsonb;
v_appointments jsonb := '[]'::jsonb;
v_documents jsonb := '[]'::jsonb;
v_services jsonb := '[]'::jsonb;
v_intakes jsonb := '[]'::jsonb;
BEGIN
-- Sanitize + length guards
v_q := nullif(btrim(coalesce(p_q, '')), '');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object(
'patients', '[]'::jsonb,
'appointments', '[]'::jsonb,
'documents', '[]'::jsonb,
'services', '[]'::jsonb,
'intakes', '[]'::jsonb
);
END IF;
v_q := left(v_q, 80);
v_pattern := '%' || v_q || '%';
v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));
-- ─────────────────────────────────────────────────────────────────────
-- Pacientes
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
p.id,
p.nome_completo,
p.email_principal,
p.telefone,
p.avatar_url,
GREATEST(
similarity(coalesce(p.nome_completo, ''), v_q),
similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
similarity(coalesce(p.telefone, ''), v_q) * 0.5,
similarity(coalesce(p.cpf, ''), v_q) * 0.6
) AS score
FROM public.patients p
WHERE p.nome_completo ILIKE v_pattern
OR p.email_principal ILIKE v_pattern
OR p.telefone ILIKE v_pattern
OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_completo,
'sublabel', coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
'avatar_url', avatar_url,
'deeplink', '/therapist/patients/cadastro/' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_patients
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Agendamentos (com nome do paciente via join)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
e.id,
coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
e.inicio_em,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(e.titulo, ''), v_q),
similarity(coalesce(e.titulo_custom, ''), v_q),
similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
) AS score
FROM public.agenda_eventos e
LEFT JOIN public.patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern
OR e.titulo_custom ILIKE v_pattern
OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', label,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
'deeplink', '/therapist/agenda?event=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_appointments
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Documentos
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
d.id,
d.patient_id,
d.nome_original,
d.tipo_documento,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(d.nome_original, ''), v_q),
similarity(coalesce(d.descricao, ''), v_q) * 0.7
) AS score
FROM public.documents d
LEFT JOIN public.patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern
OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_original,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| coalesce(tipo_documento, '')),
'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_documents
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Serviços (ativos)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
s.id,
s.name,
s.price,
s.duration_min,
GREATEST(
similarity(coalesce(s.name, ''), v_q),
similarity(coalesce(s.description, ''), v_q) * 0.7
) AS score
FROM public.services s
WHERE s.active IS TRUE
AND (s.name ILIKE v_pattern
OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', name,
'sublabel', trim(both ' · ' from
'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
|| coalesce(duration_min::text || ' min', '')),
'deeplink', '/configuracoes/precificacao',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_services
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Intakes pendentes (patient_intake_requests com status='new')
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
r.id,
r.nome_completo,
r.email_principal,
r.telefone,
r.created_at,
GREATEST(
similarity(coalesce(r.nome_completo, ''), v_q),
similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
similarity(coalesce(r.telefone, ''), v_q) * 0.5
) AS score
FROM public.patient_intake_requests r
WHERE r.status = 'new'
AND (r.nome_completo ILIKE v_pattern
OR r.email_principal ILIKE v_pattern
OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
'sublabel', trim(both ' · ' from
coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
|| 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_intakes
FROM ranked;
END IF;
RETURN jsonb_build_object(
'patients', v_patients,
'appointments', v_appointments,
'documents', v_documents,
'services', v_services,
'intakes', v_intakes
);
END;
$_$;
CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
INSERT INTO public.financial_categories (user_id, name, type, color, icon, sort_order)
VALUES
(p_user_id, 'Sess??o', 'receita', '#22c55e', 'pi pi-heart', 1),
(p_user_id, 'Supervis??o', 'receita', '#6366f1', 'pi pi-users', 2),
(p_user_id, 'Conv??nio', 'receita', '#3b82f6', 'pi pi-building', 3),
(p_user_id, 'Grupo terap??utico', 'receita', '#f59e0b', 'pi pi-sitemap', 4),
(p_user_id, 'Outro (receita)', 'receita', '#8b5cf6', 'pi pi-plus-circle', 5),
(p_user_id, 'Aluguel sala', 'despesa', '#ef4444', 'pi pi-home', 1),
(p_user_id, 'Plataforma/SaaS', 'despesa', '#f97316', 'pi pi-desktop', 2),
(p_user_id, 'Repasse cl??nica', 'despesa', '#64748b', 'pi pi-arrow-right-arrow-left', 3),
(p_user_id, 'Supervis??o (custo)', 'despesa', '#6366f1', 'pi pi-users', 4),
(p_user_id, 'Outro (despesa)', 'despesa', '#94a3b8', 'pi pi-minus-circle', 5)
ON CONFLICT DO NOTHING;
END;
$$;
CREATE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_owner_id uuid;
BEGIN
-- busca o owner (tenant_admin) do tenant
SELECT user_id INTO v_owner_id
FROM public.tenant_members
WHERE tenant_id = p_tenant_id
AND role = 'tenant_admin'
AND status = 'active'
LIMIT 1;
IF v_owner_id IS NULL THEN
RETURN;
END IF;
INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
VALUES
(v_owner_id, 'Crian??as', '#60a5fa', true, p_tenant_id),
(v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
(v_owner_id, 'Idosos', '#34d399', true, p_tenant_id)
ON CONFLICT (owner_id, nome) DO NOTHING;
END;
$$;
CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_id uuid;
begin
-- 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'
) then
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');
end if;
-- Leitura
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- 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'
) then
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');
end if;
-- Aula ??? (corrigido)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- 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'
) then
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');
end if;
-- -------------------------------------------------------
-- Campos padr??o (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Leitura
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
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 = 'book') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
end if;
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;
-- 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'
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 = 'supervisor') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
end if;
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;
-- Aula
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
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 = 'theme') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
end if;
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;
-- 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'
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 = 'analyst') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
end if;
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;
end;
$$;
CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;
CREATE FUNCTION public.set_medicos_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.set_owner_id() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if new.owner_id is null then
new.owner_id := auth.uid();
end if;
return new;
end;
$$;
CREATE FUNCTION public.set_services_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text DEFAULT NULL::text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
DECLARE
v_caller uuid := auth.uid();
v_is_saas boolean := public.is_saas_admin();
v_is_tenant_adm boolean;
v_plan_allows boolean;
v_feature_key text;
v_reason text;
v_is_exception boolean;
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização (padrão V#31)
-- ───────────────────────────────────────────────────────────────────────
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
END IF;
IF p_enabled IS NULL THEN
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
END IF;
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
IF v_feature_key IS NULL THEN
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
END IF;
IF length(v_feature_key) > 80 THEN
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
END IF;
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
END IF;
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
v_reason := substring(v_reason FROM 1 FOR 500);
END IF;
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Plano permite essa feature?
-- ───────────────────────────────────────────────────────────────────────
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements vte
WHERE vte.tenant_id = p_tenant_id
AND vte.feature_key = v_feature_key
) INTO v_plan_allows;
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
-- ───────────────────────────────────────────────────────────────────────
-- Caller é tenant_admin desse tenant?
-- ───────────────────────────────────────────────────────────────────────
v_is_tenant_adm := EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = p_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
);
-- ───────────────────────────────────────────────────────────────────────
-- Autorização (assimétrica — V#34 Opção B2)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
-- Override positivo fora do plano = exceção comercial
IF NOT v_is_saas THEN
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
END IF;
IF v_reason IS NULL THEN
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
END IF;
ELSE
-- Demais casos: tenant_admin OR saas_admin
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
END IF;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Persistência: bypass controlado do trigger guard quando é exceção
-- (escopo de transação via SET LOCAL — só esta RPC vê)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'true', true);
END IF;
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
ON CONFLICT (tenant_id, feature_key)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'false', true);
END IF;
INSERT INTO public.tenant_feature_exceptions_log
(tenant_id, feature_key, enabled, reason, created_by)
VALUES
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
RETURN jsonb_build_object(
'tenant_id', p_tenant_id,
'feature_key', v_feature_key,
'enabled', p_enabled,
'plan_allows', v_plan_allows,
'is_exception', v_is_exception,
'reason', v_reason
);
END;
$_$;
CREATE FUNCTION public.set_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; 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'
AS $$
DECLARE
v_old public.recurrence_rules;
v_new_id uuid;
BEGIN
-- busca a regra original
SELECT * INTO v_old
FROM public.recurrence_rules
WHERE id = p_recurrence_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'recurrence_rule % n??o encontrada', p_recurrence_id;
END IF;
-- encerra a regra antiga na data anterior
UPDATE public.recurrence_rules
SET
end_date = p_from_date - INTERVAL '1 day',
open_ended = false,
updated_at = now()
WHERE id = p_recurrence_id;
-- cria nova regra a partir de p_from_date
INSERT INTO public.recurrence_rules (
tenant_id, owner_id, therapist_id, patient_id,
determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min,
start_date, end_date, max_occurrences, open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
)
SELECT
tenant_id, owner_id, therapist_id, patient_id,
determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min,
p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
FROM public.recurrence_rules
WHERE id = p_recurrence_id
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
CREATE FUNCTION public.subscription_intents_view_insert() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_target text;
v_plan_id uuid;
begin
select p.id, p.target into v_plan_id, v_target
from public.plans p
where p.key = new.plan_key;
if v_plan_id is null then
raise exception 'Plano inv??lido: plan_key=%', new.plan_key;
end if;
if lower(v_target) = 'clinic' then
if new.tenant_id is null then
raise exception 'Inten????o clinic exige tenant_id.';
end if;
insert into public.subscription_intents_tenant (
id, tenant_id, created_by_user_id, email,
plan_id, plan_key, interval, amount_cents, currency,
status, source, notes, created_at, paid_at
) values (
coalesce(new.id, gen_random_uuid()),
new.tenant_id, new.created_by_user_id, new.email,
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
new.amount_cents, coalesce(new.currency,'BRL'),
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
new.notes, coalesce(new.created_at, now()), new.paid_at
);
new.plan_target := 'clinic';
return new;
end if;
-- therapist ou supervisor ??? tabela personal
if lower(v_target) in ('therapist', 'supervisor') then
insert into public.subscription_intents_personal (
id, user_id, created_by_user_id, email,
plan_id, plan_key, interval, amount_cents, currency,
status, source, notes, created_at, paid_at
) values (
coalesce(new.id, gen_random_uuid()),
new.user_id, new.created_by_user_id, new.email,
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
new.amount_cents, coalesce(new.currency,'BRL'),
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
new.notes, coalesce(new.created_at, now()), new.paid_at
);
new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor'
return new;
end if;
raise exception 'Target de plano n??o suportado: %', v_target;
end;
$$;
CREATE FUNCTION public.subscriptions_validate_scope() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_target text;
BEGIN
SELECT lower(p.target) INTO v_target
FROM public.plans p
WHERE p.id = NEW.plan_id;
IF v_target IS NULL THEN
RAISE EXCEPTION 'Plano inv??lido (target nulo).';
END IF;
IF v_target = 'clinic' THEN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
END IF;
IF NEW.user_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura clinic n??o pode ter user_id (XOR).';
END IF;
ELSIF v_target IN ('therapist', 'supervisor') THEN
-- supervisor ?? pessoal como therapist
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura % n??o deve ter tenant_id.', v_target;
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura % exige user_id.', v_target;
END IF;
ELSIF v_target = 'patient' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura patient n??o deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura patient exige user_id.';
END IF;
ELSE
RAISE EXCEPTION 'Target de plano inv??lido: %', v_target;
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.sync_busy_mirror_agenda_eventos() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
clinic_tenant uuid;
is_personal boolean;
should_mirror boolean;
begin
-- Anti-recurs??o: espelho n??o espelha
if (tg_op <> 'DELETE') then
if new.mirror_of_event_id is not null then
return new;
end if;
else
if old.mirror_of_event_id is not null then
return old;
end if;
end if;
-- Define se ?? pessoal e se deve espelhar
if (tg_op = 'DELETE') then
is_personal := (old.tenant_id = old.owner_id);
should_mirror := (old.visibility_scope in ('busy_only','private'));
else
is_personal := (new.tenant_id = new.owner_id);
should_mirror := (new.visibility_scope in ('busy_only','private'));
end if;
-- Se n??o ?? pessoal, n??o faz nada
if not is_personal then
if (tg_op = 'DELETE') then
return old;
end if;
return new;
end if;
-- DELETE: remove espelhos existentes
if (tg_op = 'DELETE') then
delete from public.agenda_eventos e
where e.mirror_of_event_id = old.id
and e.mirror_source = 'personal_busy_mirror';
return old;
end if;
-- INSERT/UPDATE:
-- Se n??o deve espelhar, remove espelhos e sai
if not should_mirror then
delete from public.agenda_eventos e
where e.mirror_of_event_id = new.id
and e.mirror_source = 'personal_busy_mirror';
return new;
end if;
-- Para cada cl??nica onde o usu??rio ?? therapist active, cria/atualiza o "Ocupado"
for clinic_tenant in
select tm.tenant_id
from public.tenant_members tm
where tm.user_id = new.owner_id
and tm.role = 'therapist'
and tm.status = 'active'
and tm.tenant_id <> new.owner_id
loop
insert into public.agenda_eventos (
tenant_id,
owner_id,
terapeuta_id,
paciente_id,
tipo,
status,
titulo,
observacoes,
inicio_em,
fim_em,
mirror_of_event_id,
mirror_source,
visibility_scope,
created_at,
updated_at
) values (
clinic_tenant,
new.owner_id,
new.owner_id,
null,
'bloqueio'::public.tipo_evento_agenda,
'agendado'::public.status_evento_agenda,
'Ocupado',
null,
new.inicio_em,
new.fim_em,
new.id,
'personal_busy_mirror',
'public',
now(),
now()
)
on conflict (tenant_id, mirror_of_event_id) where mirror_of_event_id is not null
do update set
owner_id = excluded.owner_id,
terapeuta_id = excluded.terapeuta_id,
tipo = excluded.tipo,
status = excluded.status,
titulo = excluded.titulo,
observacoes = excluded.observacoes,
inicio_em = excluded.inicio_em,
fim_em = excluded.fim_em,
updated_at = now();
end loop;
-- Limpa espelhos de cl??nicas onde o v??nculo therapist active n??o existe mais
delete from public.agenda_eventos e
where e.mirror_of_event_id = new.id
and e.mirror_source = 'personal_busy_mirror'
and not exists (
select 1
from public.tenant_members tm
where tm.user_id = new.owner_id
and tm.role = 'therapist'
and tm.status = 'active'
and tm.tenant_id = e.tenant_id
);
return new;
end;
$$;
CREATE FUNCTION public.sync_legacy_email_fields() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
SELECT email INTO v_primary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
SELECT email INTO v_secondary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET email_principal = v_primary,
email_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE public.medicos
SET email = v_primary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$;
CREATE FUNCTION public.sync_legacy_phone_fields() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
v_whatsapp_slug TEXT;
v_whatsapp TEXT;
BEGIN
-- Identifica entidade afetada (pode ser OLD em delete)
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
-- Pega primary (ou primeiro)
SELECT number INTO v_primary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
-- Pega segundo (depois do primary)
SELECT number INTO v_secondary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
-- Sincroniza campos legados
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET telefone = v_primary,
telefone_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
-- Medicos: telefone_profissional = primary; telefone_pessoal = secundario
UPDATE public.medicos
SET telefone_profissional = v_primary,
telefone_pessoal = v_secondary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.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;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
CREATE FUNCTION public.tenant_accept_invite(p_token uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'auth'
AS $$
declare
v_uid uuid;
v_email text;
v_invite public.tenant_invites%rowtype;
begin
-- 1) precisa estar autenticado
v_uid := auth.uid();
if v_uid is null then
raise exception 'not_authenticated' using errcode = 'P0001';
end if;
-- 2) pega email real do usu??rio logado sem depender do JWT claim
select u.email
into v_email
from auth.users u
where u.id = v_uid;
if v_email is null or length(trim(v_email)) = 0 then
raise exception 'missing_user_email' using errcode = 'P0001';
end if;
-- 3) carrega o invite e trava linha (evita 2 aceites concorrentes)
select *
into v_invite
from public.tenant_invites i
where i.token = p_token
for update;
if not found then
raise exception 'invite_not_found' using errcode = 'P0001';
end if;
-- 4) valida????es de estado
if v_invite.revoked_at is not null then
raise exception 'invite_revoked' using errcode = 'P0001';
end if;
if v_invite.accepted_at is not null then
raise exception 'invite_already_accepted' using errcode = 'P0001';
end if;
if v_invite.expires_at is not null and v_invite.expires_at <= now() then
raise exception 'invite_expired' using errcode = 'P0001';
end if;
-- 5) valida email (case-insensitive)
if lower(trim(v_invite.email)) <> lower(trim(v_email)) then
raise exception 'email_mismatch' using errcode = 'P0001';
end if;
-- 6) consome o invite
update public.tenant_invites
set accepted_at = now(),
accepted_by = v_uid
where id = v_invite.id;
-- 7) cria ou reativa o membership
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_invite.tenant_id, v_uid, v_invite.role, 'active', now())
on conflict (tenant_id, user_id)
do update set
role = excluded.role,
status = 'active';
-- 8) retorno ??til pro front (voc?? j?? tenta ler tenant_id no AcceptInvitePage)
return jsonb_build_object(
'ok', true,
'tenant_id', v_invite.tenant_id,
'role', v_invite.role
);
end;
$$;
CREATE FUNCTION public.tenant_add_member_by_email(p_tenant_id uuid, p_email text, p_role text DEFAULT 'therapist'::text) RETURNS public.tenant_members
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'auth'
AS $$
declare
v_target_uid uuid;
v_member public.tenant_members%rowtype;
v_is_admin boolean;
v_email text;
begin
if p_tenant_id is null then
raise exception 'tenant_id ?? obrigat??rio';
end if;
v_email := lower(trim(coalesce(p_email, '')));
if v_email = '' then
raise exception 'email ?? obrigat??rio';
end if;
-- valida role permitida
if p_role not in ('tenant_admin','therapist','secretary','patient') then
raise exception 'role inv??lida: %', p_role;
end if;
-- apenas admin do tenant (role real no banco)
select exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.role = 'tenant_admin'
and coalesce(tm.status,'active') = 'active'
) into v_is_admin;
if not v_is_admin then
raise exception 'sem permiss??o: apenas admin da cl??nica pode adicionar membros';
end if;
-- acha usu??rio pelo e-mail no Supabase Auth
select u.id
into v_target_uid
from auth.users u
where lower(u.email) = v_email
limit 1;
if v_target_uid is null then
raise exception 'nenhum usu??rio encontrado com este e-mail';
end if;
-- cria ou reativa membro
insert into public.tenant_members (tenant_id, user_id, role, status)
values (p_tenant_id, v_target_uid, p_role, 'active')
on conflict (tenant_id, user_id)
do update set
role = excluded.role,
status = 'active'
returning * into v_member;
return v_member;
end;
$$;
CREATE FUNCTION public.tenant_feature_allowed(p_tenant_id uuid, p_feature_key text) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select exists (
select 1
from public.v_tenant_entitlements v
where v.tenant_id = p_tenant_id
and v.feature_key = p_feature_key
and coalesce(v.allowed, false) = true
);
$$;
CREATE FUNCTION public.tenant_feature_enabled(p_tenant_id uuid, p_feature_key text) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select coalesce(
(select tf.enabled
from public.tenant_features tf
where tf.tenant_id = p_tenant_id and tf.feature_key = p_feature_key),
false
);
$$;
CREATE FUNCTION public.tenant_features_guard_with_plan() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_allowed boolean;
v_bypass text;
BEGIN
-- Só valida quando está habilitando
IF new.enabled IS DISTINCT FROM true THEN
RETURN new;
END IF;
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
-- após validar que o caller é saas_admin com reason.
v_bypass := current_setting('app.allow_feature_exception', true);
IF v_bypass = 'true' THEN
RETURN new;
END IF;
-- Permitido pelo plano do tenant?
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements_full v
WHERE v.tenant_id = new.tenant_id
AND v.feature_key = new.feature_key
AND v.allowed = true
) INTO v_allowed;
IF NOT v_allowed THEN
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
new.feature_key, new.tenant_id
USING ERRCODE = 'P0001';
END IF;
RETURN new;
END;
$$;
CREATE FUNCTION public.tenant_has_feature(_tenant_id uuid, _feature text) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select
exists (
select 1
from public.v_tenant_entitlements e
where e.tenant_id = _tenant_id
and e.feature_key = _feature
and e.allowed = true
)
or exists (
select 1
from public.tenant_features tf
where tf.tenant_id = _tenant_id
and tf.feature_key = _feature
and tf.enabled = true
);
$$;
CREATE FUNCTION public.tenant_invite_member_by_email(p_tenant_id uuid, p_email text, p_role text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public', 'auth'
AS $$
declare
v_email text;
v_my_email text;
v_token uuid;
v_updated int;
begin
-- valida????es b??sicas
if p_tenant_id is null then
raise exception 'tenant_id inv??lido' using errcode = 'P0001';
end if;
v_email := lower(trim(coalesce(p_email, '')));
if v_email = '' then
raise exception 'Informe um email' using errcode = 'P0001';
end if;
-- role permitido (ajuste se quiser)
if p_role is null or p_role not in ('therapist', 'secretary') then
raise exception 'Role inv??lido (use therapist/secretary)' using errcode = 'P0001';
end if;
-- ??? bloqueio: auto-convite
v_my_email := public.get_my_email();
if v_my_email is not null and v_email = v_my_email then
raise exception 'Voc?? n??o pode convidar o seu pr??prio email.' using errcode = 'P0001';
end if;
-- ??? bloqueio: j?? ?? membro ativo do tenant
if exists (
select 1
from tenant_members tm
join auth.users au on au.id = tm.user_id
where tm.tenant_id = p_tenant_id
and tm.status = 'active'
and lower(au.email) = v_email
) then
raise exception 'Este email j?? est?? vinculado a esta cl??nica.' using errcode = 'P0001';
end if;
-- ??? permiss??o: s?? admin do tenant pode convidar
if not exists (
select 1
from tenant_members me
where me.tenant_id = p_tenant_id
and me.user_id = auth.uid()
and me.status = 'active'
and me.role in ('tenant_admin','clinic_admin')
) then
raise exception 'Sem permiss??o para convidar membros.' using errcode = 'P0001';
end if;
-- Gera token (reenvio simples / regenera????o)
v_token := gen_random_uuid();
-- 1) tenta "regerar" um convite pendente existente (mesmo email)
update tenant_invites
set token = v_token,
role = p_role,
created_at = now(),
expires_at = now() + interval '7 days',
accepted_at = null,
revoked_at = null
where tenant_id = p_tenant_id
and lower(email) = v_email
and accepted_at is null
and revoked_at is null;
get diagnostics v_updated = row_count;
-- 2) se n??o atualizou nada, cria convite novo
if v_updated = 0 then
insert into tenant_invites (tenant_id, email, role, token, created_at, expires_at)
values (p_tenant_id, v_email, p_role, v_token, now(), now() + interval '7 days');
end if;
return v_token;
end;
$$;
CREATE FUNCTION public.tenant_reactivate_member(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
begin
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
update public.tenant_members
set status = 'active'
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if not found then
raise exception 'membership_not_found';
end if;
end;
$$;
CREATE FUNCTION public.tenant_remove_member(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
declare
v_role text;
begin
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
if p_member_user_id = auth.uid() then
raise exception 'cannot_remove_self';
end if;
-- pega role atual do membro (se n??o existir, erro)
select role into v_role
from public.tenant_members
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if v_role is null then
raise exception 'membership_not_found';
end if;
-- trava: se for therapist, n??o pode remover com eventos futuros
if v_role = 'therapist' then
if exists (
select 1
from public.agenda_eventos e
where e.owner_id = p_tenant_id
and e.terapeuta_id = p_member_user_id
and e.inicio_em >= now()
and e.status::text not in ('cancelado','cancelled','canceled')
limit 1
) then
raise exception 'cannot_remove_therapist_with_future_events';
end if;
end if;
update public.tenant_members
set status = 'inactive'
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if not found then
raise exception 'membership_not_found';
end if;
end;
$$;
CREATE FUNCTION public.tenant_remove_member_soft(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
begin
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
if p_member_user_id = auth.uid() then
raise exception 'cannot_remove_self';
end if;
update public.tenant_members
set status = 'inactive'
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if not found then
raise exception 'membership_not_found';
end if;
end;
$$;
CREATE FUNCTION public.tenant_revoke_invite(p_tenant_id uuid, p_email text, p_role text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
declare
v_email text;
begin
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
v_email := lower(trim(p_email));
update public.tenant_invites
set revoked_at = now(),
revoked_by = auth.uid()
where tenant_id = p_tenant_id
and lower(email) = v_email
and role = p_role
and accepted_at is null
and revoked_at is null;
if not found then
raise exception 'invite_not_found';
end if;
end;
$$;
CREATE FUNCTION public.tenant_set_member_status(p_tenant_id uuid, p_member_user_id uuid, p_new_status text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
begin
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
-- valida status (adapte aos seus valores reais)
if p_new_status not in ('active','inactive','suspended','invited') then
raise exception 'invalid_status: %', p_new_status;
end if;
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
-- evita desativar a si mesmo (opcional)
if p_member_user_id = auth.uid() and p_new_status <> 'active' then
raise exception 'cannot_disable_self';
end if;
update public.tenant_members
set status = p_new_status
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if not found then
raise exception 'membership_not_found';
end if;
end;
$$;
CREATE FUNCTION public.tenant_update_member_role(p_tenant_id uuid, p_member_user_id uuid, p_new_role text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
SET row_security TO 'off'
AS $$
begin
-- exige auth
if auth.uid() is null then
raise exception 'not_authenticated';
end if;
-- valida role
if p_new_role not in ('tenant_admin','therapist','secretary','patient') then
raise exception 'invalid_role: %', p_new_role;
end if;
-- somente tenant_admin ativo pode alterar role
if not public.is_tenant_admin(p_tenant_id) then
raise exception 'not_allowed';
end if;
-- evita o admin remover o pr??prio admin sem querer (opcional mas recomendado)
if p_member_user_id = auth.uid() and p_new_role <> 'tenant_admin' then
raise exception 'cannot_demote_self';
end if;
update public.tenant_members
set role = p_new_role
where tenant_id = p_tenant_id
and user_id = p_member_user_id;
if not found then
raise exception 'membership_not_found';
end if;
end;
$$;
CREATE FUNCTION public.toggle_plan(owner uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
current_key text;
new_key text;
begin
select p.key into current_key
from subscriptions s
join plans p on p.id = s.plan_id
where s.owner_id = owner
and s.status = 'active';
new_key := case
when current_key = 'pro' then 'free'
else 'pro'
end;
update subscriptions s
set plan_id = p.id
from plans p
where p.key = new_key
and s.owner_id = owner
and s.status = 'active';
end;
$$;
CREATE FUNCTION public.transition_subscription(p_subscription_id uuid, p_to_status text, p_reason text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_sub public.subscriptions;
v_uid uuid;
v_is_allowed boolean := false;
begin
v_uid := auth.uid();
select *
into v_sub
from public.subscriptions
where id = p_subscription_id;
if not found then
raise exception 'Assinatura n??o encontrada';
end if;
-- =====================================================
-- ???? BLOCO DE AUTORIZA????O
-- =====================================================
-- 1) SaaS admin pode tudo
if is_saas_admin() then
v_is_allowed := true;
end if;
-- 2) Assinatura pessoal (therapist)
if not v_is_allowed
and v_sub.tenant_id is null
and v_sub.user_id = v_uid then
v_is_allowed := true;
end if;
-- 3) Assinatura de clinic (tenant)
if not v_is_allowed
and v_sub.tenant_id is not null then
if exists (
select 1
from public.tenant_members tm
where tm.tenant_id = v_sub.tenant_id
and tm.user_id = v_uid
and tm.status = 'active'
and tm.role = 'tenant_admin'
) then
v_is_allowed := true;
end if;
end if;
if not v_is_allowed then
raise exception 'Sem permiss??o para transicionar esta assinatura';
end if;
-- =====================================================
-- ???? TRANSI????O
-- =====================================================
update public.subscriptions
set status = p_to_status,
updated_at = now(),
cancelled_at = case when p_to_status = 'cancelled' then now() else cancelled_at end,
suspended_at = case when p_to_status = 'suspended' then now() else suspended_at end,
past_due_since = case when p_to_status = 'past_due' then now() else past_due_since end,
expired_at = case when p_to_status = 'expired' then now() else expired_at end,
activated_at = case when p_to_status = 'active' then now() else activated_at end
where id = p_subscription_id
returning * into v_sub;
-- =====================================================
-- ???? EVENT LOG
-- =====================================================
insert into public.subscription_events (
subscription_id,
owner_id,
event_type,
created_at,
created_by,
source,
reason,
metadata,
owner_type,
owner_ref
)
values (
v_sub.id,
coalesce(v_sub.tenant_id, v_sub.user_id),
'status_changed',
now(),
v_uid,
'manual_transition',
p_reason,
p_metadata,
case when v_sub.tenant_id is not null then 'tenant' else 'personal' end,
coalesce(v_sub.tenant_id, v_sub.user_id)
);
return v_sub;
end;
$$;
CREATE FUNCTION public.trg_fn_financial_records_auto_overdue() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NEW.status = 'pending'
AND NEW.due_date IS NOT NULL
AND NEW.due_date < CURRENT_DATE
THEN
NEW.status := 'overdue';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.trg_fn_patient_risco_timeline() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
NEW.risco_nota,
CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.trg_fn_patient_status_history() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_status_history (
patient_id, tenant_id,
status_anterior, status_novo,
motivo, encaminhado_para, data_saida,
alterado_por, alterado_em
) VALUES (
NEW.id, NEW.tenant_id,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
NEW.status,
NEW.motivo_saida,
NEW.encaminhado_para,
NEW.data_saida,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.trg_fn_patient_status_timeline() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id,
evento_tipo, titulo, descricao, icone_cor,
gerado_por, ocorrido_em
) VALUES (
NEW.id, NEW.tenant_id,
'status_alterado',
'Status alterado para ' || NEW.status,
CASE
WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado'
ELSE 'De ' || OLD.status || '' || NEW.status ||
CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END
END,
CASE NEW.status
WHEN 'Ativo' THEN 'green'
WHEN 'Alta' THEN 'blue'
WHEN 'Inativo' THEN 'gray'
WHEN 'Encaminhado' THEN 'amber'
WHEN 'Arquivado' THEN 'gray'
ELSE 'gray'
END,
auth.uid(),
now()
);
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION public.trg_set_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$ BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_unstuck integer;
BEGIN
UPDATE public.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';
GET DIAGNOSTICS v_unstuck = ROW_COUNT;
RETURN v_unstuck;
END;
$$;
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.update_professional_pricing_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE FUNCTION public.update_twilio_config(p_account_sid text DEFAULT NULL::text, p_whatsapp_webhook_url text DEFAULT NULL::text, p_usd_brl_rate numeric DEFAULT NULL::numeric, p_margin_multiplier numeric DEFAULT NULL::numeric, p_notes text DEFAULT NULL::text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
DECLARE
v_caller uuid := auth.uid();
v_account_sid text;
v_webhook_url text;
v_notes text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
END IF;
-- Sanitização
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
END IF;
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
END IF;
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
END IF;
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
END IF;
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
v_notes := substring(v_notes FROM 1 FOR 1000);
END IF;
UPDATE saas_twilio_config
SET account_sid = COALESCE(v_account_sid, account_sid),
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
notes = COALESCE(v_notes, notes),
updated_at = now(),
updated_by = v_caller
WHERE id = true;
RETURN public.get_twilio_config();
END;
$_$;
CREATE FUNCTION public.user_has_feature(_user_id uuid, _feature text) RETURNS boolean
LANGUAGE sql STABLE
AS $$
select exists (
select 1
from public.v_user_entitlements e
where e.user_id = _user_id
and e.feature_key = _feature
and e.allowed = true
);
$$;
CREATE FUNCTION public.validate_share_token(p_token text) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token text;
BEGIN
v_token := nullif(btrim(coalesce(p_token, '')), '');
IF v_token IS NULL THEN
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
END IF;
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF sl.ativo IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_id,
'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes
);
END;
$$;
CREATE FUNCTION public.validate_support_session(p_token text) RETURNS json
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_session support_sessions;
BEGIN
IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
SELECT * INTO v_session
FROM public.support_sessions
WHERE token = p_token
AND expires_at > now()
LIMIT 1;
IF NOT FOUND THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
RETURN json_build_object(
'valid', true,
'tenant_id', v_session.tenant_id
);
END;
$$;
CREATE FUNCTION public.verify_math_challenge(p_id uuid, p_answer integer) RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$$;
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE
AS $$
select auth.uid() as uid, auth.role() as role;
$$;