Files
agenciapsilmno/database-novo/schema/03_functions/public.sql
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

4547 lines
133 KiB
PL/PgSQL

-- Functions: public
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
-- Total: 126
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.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.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_intake_id uuid;
v_birth_raw text;
v_birth date;
begin
select owner_id
into v_owner_id
from public.patient_invites
where token = p_token;
if v_owner_id is null then
raise exception 'Token inv??lido ou expirado';
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;
insert into public.patient_intake_requests (
owner_id,
token,
status,
consent,
nome_completo,
email_principal,
telefone,
avatar_url, -- ???? AQUI
data_nascimento,
cpf,
rg,
genero,
estado_civil,
profissao,
escolaridade,
nacionalidade,
naturalidade,
cep,
pais,
cidade,
estado,
endereco,
numero,
complemento,
bairro,
observacoes,
notas_internas,
encaminhado_por,
onde_nos_conheceu
)
values (
v_owner_id,
p_token,
'new',
coalesce((p_payload->>'consent')::boolean, false),
nullif(trim(p_payload->>'nome_completo'), ''),
nullif(trim(p_payload->>'email_principal'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'avatar_url'), ''), -- ???? AQUI
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'rg'), ''),
nullif(trim(p_payload->>'genero'), ''),
nullif(trim(p_payload->>'estado_civil'), ''),
nullif(trim(p_payload->>'profissao'), ''),
nullif(trim(p_payload->>'escolaridade'), ''),
nullif(trim(p_payload->>'nacionalidade'), ''),
nullif(trim(p_payload->>'naturalidade'), ''),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'pais'), ''),
nullif(trim(p_payload->>'cidade'), ''),
nullif(trim(p_payload->>'estado'), ''),
nullif(trim(p_payload->>'endereco'), ''),
nullif(trim(p_payload->>'numero'), ''),
nullif(trim(p_payload->>'complemento'), ''),
nullif(trim(p_payload->>'bairro'), ''),
nullif(trim(p_payload->>'observacoes'), ''),
nullif(trim(p_payload->>'notas_internas'), ''),
nullif(trim(p_payload->>'encaminhado_por'), ''),
nullif(trim(p_payload->>'onde_nos_conheceu'), '')
)
returning id into v_intake_id;
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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 void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
begin
-- ??? S?? owner ou admin do tenant podem alterar features
if not exists (
select 1 from public.tenant_members
where tenant_id = p_tenant_id
and user_id = auth.uid()
and role in ('owner', 'admin')
and status = 'active'
) then
raise exception 'Acesso negado: apenas owner/admin pode alterar features do tenant.';
end if;
insert into public.tenant_features (tenant_id, feature_key, enabled)
values (p_tenant_id, p_feature_key, p_enabled)
on conflict (tenant_id, feature_key)
do update set enabled = excluded.enabled;
insert into public.tenant_feature_exceptions_log (
tenant_id, feature_key, enabled, reason, created_by
) values (
p_tenant_id, p_feature_key, p_enabled, p_reason, auth.uid()
);
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_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;
begin
-- s?? valida quando est?? habilitando
if new.enabled is distinct from 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.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_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.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE
AS $$
select auth.uid() as uid, auth.role() as role;
$$;