-- 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; $$;