2644e60bb6
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>
6427 lines
207 KiB
PL/PgSQL
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;
|
|
$$;
|